mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/settings/appearance-density.png" alt="Appearance settings showing the Density selector" />
|
||||
</Frame>
|
||||
|
||||
| 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<Density>(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];
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
|
||||
Reference in New Issue
Block a user