diff --git a/docs/images/settings/appearance-density.png b/docs/images/settings/appearance-density.png
new file mode 100644
index 00000000..6547a615
Binary files /dev/null and b/docs/images/settings/appearance-density.png differ
diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx
index 828d7c7f..ae5c5abe 100644
--- a/docs/reference/settings.mdx
+++ b/docs/reference/settings.mdx
@@ -13,7 +13,7 @@ Open the Settings Hub by clicking the **Profile** icon in the top bar and select
| Group | What it covers |
|-------|----------------|
-| **Identity** | Account, License, Users, SSO, API Tokens |
+| **Identity** | Account, Appearance, License, Users, SSO, API Tokens |
| **System** (node-scoped) | System Limits, Registries, Nodes |
| **Alerts** | Notifications, Routing, Webhooks |
| **Advanced** | Labels, Security, Developer, App Store, Support, About |
@@ -54,6 +54,25 @@ Click **Update Password** to apply. The change takes effect immediately; existin
---
+## Appearance
+
+**Scope:** Per-device (saved to this browser only)
+
+Control how much information Sencho packs on screen. Each browser you sign in from remembers its own choice, so a compact laptop setup does not force the same rhythm on a larger desktop.
+
+
+
+
+
+| Density | When to pick it |
+|---------|-----------------|
+| **Comfortable** | Default spacing. Roomier rows and tiles for review and orientation. |
+| **Compact** | Tighter rows and tiles. Fits more stacks, tasks, and audit entries on screen at once. |
+
+Density affects the dashboard stack table, the resource gauge strip, the Settings Hub sidebar, the Schedules and Audit Log tables, and every other data table in Sencho. Typography, color, and layout structure stay the same; only vertical padding compresses.
+
+---
+
## License
**Scope:** Global
diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx
index 4bc1ca28..2a1419f3 100644
--- a/frontend/src/components/AuditLogView.tsx
+++ b/frontend/src/components/AuditLogView.tsx
@@ -565,7 +565,7 @@ function StreamRow({ entry, now }: StreamRowProps) {
'';
return (
-
+
{formatRelative(entry.timestamp, now)}
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 61a2ad38..c9bd5dcc 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -31,6 +31,7 @@ import { ApiTokensSection } from './ApiTokensSection';
import { RegistriesSection } from './RegistriesSection';
import {
AccountSection,
+ AppearanceSection,
LicenseSection,
UsersSection,
SystemSection,
@@ -298,6 +299,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
isSaving={isSavingPassword}
/>
);
+ case 'appearance': return
;
case 'license': return
;
case 'users': return
;
case 'sso': return
;
@@ -394,7 +396,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
key={item.id}
onClick={() => switchSection(item.id)}
className={cn(
- 'relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-left transition-colors',
+ 'relative flex items-center gap-2 rounded-sm px-2 py-[var(--density-cell-y)] text-left transition-colors',
isActive
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
diff --git a/frontend/src/components/dashboard/ResourceGauges.tsx b/frontend/src/components/dashboard/ResourceGauges.tsx
index ce54ea2b..f51f4939 100644
--- a/frontend/src/components/dashboard/ResourceGauges.tsx
+++ b/frontend/src/components/dashboard/ResourceGauges.tsx
@@ -71,7 +71,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
return (
{/* CPU hero */}
-
+
CPU{systemStats ? ` ยท ${systemStats.cpu.cores} cores` : ''}
@@ -95,7 +95,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
{/* Memory */}
-
+
MEMORY
@@ -109,7 +109,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
{/* Disk */}
-
+
DISK
@@ -123,7 +123,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
{/* Network */}
-
+
NETWORK
diff --git a/frontend/src/components/dashboard/StackHealthTable.tsx b/frontend/src/components/dashboard/StackHealthTable.tsx
index a80fbeaa..a3628742 100644
--- a/frontend/src/components/dashboard/StackHealthTable.tsx
+++ b/frontend/src/components/dashboard/StackHealthTable.tsx
@@ -184,7 +184,7 @@ export function StackHealthTable({
) : null}
-
+
STACK
HOST
@@ -207,7 +207,7 @@ export function StackHealthTable({
onNavigateToStack(row.file);
}
}}
- className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-5 py-3 transition-colors hover:bg-accent/5 ${rowTint[row.state]}`}
+ className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`}
>
{row.name}
diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx
new file mode 100644
index 00000000..4bde3fe2
--- /dev/null
+++ b/frontend/src/components/settings/AppearanceSection.tsx
@@ -0,0 +1,42 @@
+import { Label } from '@/components/ui/label';
+import { Combobox } from '@/components/ui/combobox';
+import { useDensity } from '@/hooks/use-density';
+import type { Density } from '@/hooks/use-density';
+
+const DENSITY_OPTIONS: { value: Density; label: string }[] = [
+ { value: 'comfortable', label: 'Comfortable' },
+ { value: 'compact', label: 'Compact' },
+];
+
+const DENSITY_DESCRIPTIONS: Record
= {
+ comfortable: 'Default spacing. Roomy rows for review and orientation.',
+ compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
+};
+
+export function AppearanceSection() {
+ const [density, setDensity] = useDensity();
+
+ return (
+
+
+
+
Density
+
{
+ if (v === 'comfortable' || v === 'compact') setDensity(v);
+ }}
+ placeholder="Select density"
+ />
+
+ {DENSITY_DESCRIPTIONS[density]}
+
+
+
+
+ Preference is saved to this browser only. Each device you use remembers its own choice.
+
+
+ );
+}
diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts
index 6363087d..d6a2d2b6 100644
--- a/frontend/src/components/settings/index.ts
+++ b/frontend/src/components/settings/index.ts
@@ -1,4 +1,5 @@
export { AccountSection } from './AccountSection';
+export { AppearanceSection } from './AppearanceSection';
export { LicenseSection } from './LicenseSection';
export { UsersSection } from './UsersSection';
export { SystemSection } from './SystemSection';
diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts
index 870a424b..74b1635d 100644
--- a/frontend/src/components/settings/registry.ts
+++ b/frontend/src/components/settings/registry.ts
@@ -42,6 +42,15 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
scope: 'global',
hiddenOnRemote: true,
},
+ {
+ id: 'appearance',
+ group: 'identity',
+ label: 'Appearance',
+ description: 'Density and display preferences saved to this browser.',
+ keywords: ['density', 'comfortable', 'compact', 'spacing', 'display', 'theme'],
+ tier: null,
+ scope: 'global',
+ },
{
id: 'license',
group: 'identity',
diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts
index b02dbf33..cff72d43 100644
--- a/frontend/src/components/settings/types.ts
+++ b/frontend/src/components/settings/types.ts
@@ -28,6 +28,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
export type SectionId =
| 'account'
+ | 'appearance'
| 'license'
| 'users'
| 'sso'
diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx
index c0df655c..1a457265 100644
--- a/frontend/src/components/ui/table.tsx
+++ b/frontend/src/components/ui/table.tsx
@@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
[role=checkbox]]:translate-y-[2px]",
+ "h-[calc(2.5rem*var(--density-scale,1))] px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
@@ -88,7 +88,7 @@ const TableCell = React.forwardRef<
[role=checkbox]]:translate-y-[2px]",
+ "px-2 py-[var(--density-cell-y)] align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
diff --git a/frontend/src/hooks/use-density.ts b/frontend/src/hooks/use-density.ts
new file mode 100644
index 00000000..7b70ea30
--- /dev/null
+++ b/frontend/src/hooks/use-density.ts
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type Density = 'comfortable' | 'compact';
+
+const STORAGE_KEY = 'sencho.appearance.density';
+const DEFAULT_DENSITY: Density = 'comfortable';
+
+function isDensity(value: unknown): value is Density {
+ return value === 'comfortable' || value === 'compact';
+}
+
+function readStoredDensity(): Density {
+ if (typeof window === 'undefined') return DEFAULT_DENSITY;
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ return isDensity(raw) ? raw : DEFAULT_DENSITY;
+ } catch {
+ return DEFAULT_DENSITY;
+ }
+}
+
+function applyDensityClass(density: Density) {
+ if (typeof document === 'undefined') return;
+ const body = document.body;
+ if (!body) return;
+ body.classList.toggle('density-compact', density === 'compact');
+}
+
+export function initializeDensity() {
+ applyDensityClass(readStoredDensity());
+}
+
+export function useDensity(): [Density, (next: Density) => void] {
+ const [density, setDensityState] = useState(readStoredDensity);
+
+ useEffect(() => {
+ applyDensityClass(density);
+ try {
+ if (window.localStorage.getItem(STORAGE_KEY) !== density) {
+ window.localStorage.setItem(STORAGE_KEY, density);
+ }
+ } catch {
+ // ignore; localStorage may be unavailable (private mode, quota)
+ }
+ }, [density]);
+
+ useEffect(() => {
+ function onStorage(event: StorageEvent) {
+ if (event.key !== STORAGE_KEY) return;
+ if (isDensity(event.newValue)) setDensityState(event.newValue);
+ }
+ window.addEventListener('storage', onStorage);
+ return () => window.removeEventListener('storage', onStorage);
+ }, []);
+
+ const setDensity = useCallback((next: Density) => {
+ setDensityState(next);
+ }, []);
+
+ return [density, setDensity];
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c1e754a3..0f81af3a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -160,6 +160,14 @@
--tracking-normal: 0em;
--spacing: 0.25rem;
+ /* Density: comfortable defaults. Overridden under .density-compact. */
+ --density-scale: 1; /* proportional scale for fixed dimensions */
+ --density-row-y: 0.75rem; /* py-3 */
+ --density-row-x: 1.25rem; /* px-5 */
+ --density-cell-y: 0.5rem; /* py-2 */
+ --density-tile-y: 1rem; /* py-4 */
+ --density-gap: 1rem; /* gap-4 */
+
/* Terminal (dark well even in light theme) */
--terminal-bg: oklch(0.12 0 0);
--terminal-fg: oklch(0.85 0 0);
@@ -431,6 +439,16 @@
}
}
+/* Density: compact mode compresses row/cell padding by ~50%. */
+body.density-compact {
+ --density-scale: 0.75;
+ --density-row-y: 0.375rem;
+ --density-row-x: 1rem;
+ --density-cell-y: 0.25rem;
+ --density-tile-y: 0.625rem;
+ --density-gap: 0.75rem;
+}
+
/* Dark mode: teal-tinted ambient glow */
.dark body {
background:
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 52d7f5fb..e648c8dc 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import ErrorBoundary from './components/ErrorBoundary.tsx'
+import { initializeDensity } from './hooks/use-density'
import * as monaco from 'monaco-editor'
import { loader } from '@monaco-editor/react'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
@@ -18,6 +19,8 @@ window.MonacoEnvironment = {
}
loader.config({ monaco })
+initializeDensity()
+
createRoot(document.getElementById('root')!).render(