feat(settings): add comfortable/compact density toggle (#683)

Adds a per-device appearance preference that compresses row, cell, and
tile padding across dashboard, settings, audit log, and every shared
table without changing typography or layout structure.

Density is stored in localStorage and applied to the document body as a
class that swaps a set of CSS variables. Components opt in by consuming
the tokens, so the global table primitive scales all seven consumers at
once.

A new Appearance section in the Identity group lets users pick between
Comfortable and Compact via a Combobox, with a helper line that
reflects the current choice.
This commit is contained in:
Anso
2026-04-18 18:48:58 -04:00
committed by GitHub
parent 591dc75d1e
commit ad90bd9404
14 changed files with 167 additions and 11 deletions
+1 -1
View File
@@ -565,7 +565,7 @@ function StreamRow({ entry, now }: StreamRowProps) {
'';
return (
<div className={`grid grid-cols-[72px_10px_1fr_auto] gap-3 items-start px-2 py-2 rounded-sm ${rowTint}`}>
<div className={`grid grid-cols-[72px_10px_1fr_auto] gap-3 items-start px-2 py-[var(--density-cell-y)] rounded-sm ${rowTint}`}>
<div className="text-[11px] font-mono text-stat-subtitle tabular-nums leading-4 pt-0.5">
{formatRelative(entry.timestamp, now)}
</div>
+3 -1
View File
@@ -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 <AppearanceSection />;
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
@@ -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',
@@ -71,7 +71,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
return (
<div className="grid grid-cols-1 overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel md:grid-cols-[2fr_1fr_1fr_1fr]">
{/* CPU hero */}
<div className="relative p-5 md:border-r md:border-border/60">
<div className="relative px-[var(--density-row-x)] py-[var(--density-tile-y)] md:border-r md:border-border/60">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
CPU{systemStats ? ` · ${systemStats.cpu.cores} cores` : ''}
</div>
@@ -95,7 +95,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
</div>
{/* Memory */}
<div className="p-5 border-t border-border/60 md:border-t-0 md:border-r">
<div className="px-[var(--density-row-x)] py-[var(--density-tile-y)] border-t border-border/60 md:border-t-0 md:border-r">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
MEMORY
</div>
@@ -109,7 +109,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
</div>
{/* Disk */}
<div className="p-5 border-t border-border/60 md:border-t-0 md:border-r">
<div className="px-[var(--density-row-x)] py-[var(--density-tile-y)] border-t border-border/60 md:border-t-0 md:border-r">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
DISK
</div>
@@ -123,7 +123,7 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd
</div>
{/* Network */}
<div className="p-5 border-t border-border/60 md:border-t-0">
<div className="px-[var(--density-row-x)] py-[var(--density-tile-y)] border-t border-border/60 md:border-t-0">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
NETWORK
</div>
@@ -184,7 +184,7 @@ export function StackHealthTable({
</div>
) : null}
</div>
<div className={`grid ${GRID_TEMPLATE} items-center gap-4 border-t border-border/60 px-5 py-2 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
<div className={`grid ${GRID_TEMPLATE} items-center gap-4 border-t border-border/60 px-[var(--density-row-x)] py-[var(--density-cell-y)] font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
<span />
<span>STACK</span>
<span>HOST</span>
@@ -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]}`}
>
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
<span className="truncate font-mono text-[13px] text-stat-value">{row.name}</span>
@@ -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<Density, string> = {
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 (
<div className="space-y-6">
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-2">
<Label htmlFor="density-select">Density</Label>
<Combobox
options={DENSITY_OPTIONS}
value={density}
onValueChange={(v) => {
if (v === 'comfortable' || v === 'compact') setDensity(v);
}}
placeholder="Select density"
/>
<p className="text-xs text-stat-subtitle">
{DENSITY_DESCRIPTIONS[density]}
</p>
</div>
</div>
<p className="text-xs text-stat-subtitle">
Preference is saved to this browser only. Each device you use remembers its own choice.
</p>
</div>
);
}
@@ -1,4 +1,5 @@
export { AccountSection } from './AccountSection';
export { AppearanceSection } from './AppearanceSection';
export { LicenseSection } from './LicenseSection';
export { UsersSection } from './UsersSection';
export { SystemSection } from './SystemSection';
@@ -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',
@@ -28,6 +28,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
export type SectionId =
| 'account'
| 'appearance'
| 'license'
| 'users'
| 'sso'
+2 -2
View File
@@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[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<
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[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}