diff --git a/docs/docs.json b/docs/docs.json index 428cd88b..7874d59d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -94,6 +94,7 @@ "group": "Features", "pages": [ "features/overview", + "features/appearance", { "group": "Stacks", "expanded": true, diff --git a/docs/features/appearance.mdx b/docs/features/appearance.mdx new file mode 100644 index 00000000..b6a82a24 --- /dev/null +++ b/docs/features/appearance.mdx @@ -0,0 +1,53 @@ +--- +title: Appearance +description: Personalize Sencho's look. Choose a theme and accent, fine-tune contrast, borders, and glow, swap the interface and data fonts, and set the text size. Every choice is saved to the current browser. +--- + +**Settings → Appearance** is where you make Sencho look the way you want. Pick a theme and accent color, fine-tune contrast and the ambient glow, choose the interface and data fonts, and scale the text. A live preview at the top of the section reflects every change as you make it. + +Most of these controls are also one click away from anywhere in the app: the **palette button** in the top bar, between the search and notification icons, opens a quick switcher for the theme mode, accent, and fonts. + + + Appearance choices are saved to **this browser only**. They follow you across tabs on the same browser, and every device remembers its own look. Nothing here changes what other operators see. + + +## Theme + +The **Mode** control sets the overall surface palette: + +- **Dim** (default) is a raised charcoal. Cards and panels sit clearly above the page without going fully black. +- **OLED** is true black, ideal for power-saving panels. Borders stay visible so cards never merge into the background. +- **Light** is the bright theme, with the same directional, opaque borders inverted for a white surface. +- **Auto** follows your operating system, switching between Dim and Light as your OS does, and re-resolving live when the OS flips. + +## Accent + +The **accent** is Sencho's one data color. It drives charts, sparklines, progress bars, focus rings, active states, the masthead and sidebar rails, and the ambient page glow, so changing it recolors the whole product cohesively. + +Eight well-spaced hues are available: **Cyan** (the default and the signature), **Blue**, **Violet**, **Magenta**, **Orange**, **Amber**, **Lime**, and **Steel** (a deliberate near-neutral). Status colors (green for healthy, amber for warning, rose for error) stay constant regardless of accent so state always reads the same. + +## Fine-tune + +Three sliders shape the surfaces and atmosphere. A **Reset fine-tune** button returns all three to their defaults. + +- **Contrast** is a master control that spreads the page tone, every border, and the text tiers together in one move. Higher contrast darkens the page, brightens borders, and crisps up secondary text. Pairing high contrast with the OLED theme gives the sharpest reading on a true-black panel. +- **Border brightness** lifts or softens every hairline on its own, so the separation between cards reads exactly how you like it. +- **Ambient glow** sets the intensity of the soft, accent-tinted glow behind the page. Slide it to zero for a completely flat background. + +## Typography + +Sencho uses three type contexts. The display face for hero headings stays fixed as the signature; the other two are yours to change. + +- **Interface font** sets the sans face used for body text, labels, navigation, and buttons. Choose **Geist** (default), **IBM Plex Sans**, or **Hanken Grotesk**. +- **Data font** sets the monospace face used for the terminal, stat values, codes, and timestamps. Choose **Geist Mono** (default), **IBM Plex Mono**, or **Fira Code**. +- **Text size** scales the entire interface from a single root multiplier. In Settings this is a continuous slider (default `1.00×`); the top-bar quick switcher offers **S / M / L / XL** presets. Both stay in sync. + +Hero headings and featured names always render in Instrument Serif so the editorial voice stays consistent no matter which interface font you pick. + +## Display + +The **Display** group holds the remaining per-browser preferences: + +- **Density** switches between **Comfortable** (roomy rows, the default) and **Compact** (tighter rows and tiles that fit more on screen for dense dashboards). +- **Deploy progress modal** toggles the live streaming output window for deploy, restart, update, install, and Git operations. +- **Diff preview before save** toggles a side-by-side diff of compose and env edits before they are written to disk. diff --git a/frontend/index.html b/frontend/index.html index 75aefc8f..3d1bb97e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@ Sencho - + diff --git a/frontend/public/theme-init.js b/frontend/public/theme-init.js index 91e5f5f4..a89f039d 100644 --- a/frontend/public/theme-init.js +++ b/frontend/public/theme-init.js @@ -1,10 +1,79 @@ (function () { - try { - var saved = localStorage.getItem('sencho-theme'); - if (saved === 'light') { - document.documentElement.classList.remove('dark'); - } else { - document.documentElement.classList.add('dark'); + // Pre-paint: apply the saved theme/accent/knobs/type to before first + // paint so there is no flash. Mirrors the apply logic in src/hooks/use-theme.ts; + // keep the two in sync. Dependency-free on purpose (runs in ). + var STORAGE_KEY = 'sencho.appearance.theme'; + var LEGACY_KEY = 'sencho-theme'; + var MODES = { dim: 1, oled: 1, light: 1, auto: 1 }; + var ACCENTS = { + cyan: 1, blue: 1, violet: 1, magenta: 1, + orange: 1, amber: 1, lime: 1, steel: 1, + }; + var UI_FONTS = { 'Geist': 1, 'IBM Plex Sans': 1, 'Hanken Grotesk': 1 }; + var MONO_FONTS = { 'Geist Mono': 1, 'IBM Plex Mono': 1, 'Fira Code': 1 }; + var DEFAULTS = { + theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0, + uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1, + }; + + function num(v, min, max, def) { + if (typeof v !== 'number' || !isFinite(v)) return def; + return Math.min(max, Math.max(min, v)); + } + + function read() { + try { + var raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + var p = JSON.parse(raw); + if (p && typeof p === 'object') { + return { + theme: MODES[p.theme] ? p.theme : DEFAULTS.theme, + accent: ACCENTS[p.accent] ? p.accent : DEFAULTS.accent, + borderBoost: num(p.borderBoost, -0.06, 0.12, DEFAULTS.borderBoost), + glow: num(p.glow, 0, 0.4, DEFAULTS.glow), + contrast: num(p.contrast, -0.6, 1.2, DEFAULTS.contrast), + uiFont: UI_FONTS[p.uiFont] ? p.uiFont : DEFAULTS.uiFont, + monoFont: MONO_FONTS[p.monoFont] ? p.monoFont : DEFAULTS.monoFont, + typeScale: num(p.typeScale, 0.88, 1.2, DEFAULTS.typeScale), + }; + } + } + // Legacy migration: old string key held only the mode (dark mapped to dim). + var legacy = localStorage.getItem(LEGACY_KEY); + var legacyTheme = legacy === 'light' || legacy === 'auto' ? legacy : legacy === 'dark' ? 'dim' : null; + if (legacyTheme) { + var migrated = {}; + for (var k in DEFAULTS) migrated[k] = DEFAULTS[k]; + migrated.theme = legacyTheme; + return migrated; + } + } catch (e) { /* ignore */ } + var out = {}; + for (var d in DEFAULTS) out[d] = DEFAULTS[d]; + return out; + } + + function resolveTheme(theme) { + if (theme === 'auto') { + var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return dark ? 'dim' : 'light'; } - } catch (e) { } + return theme; + } + + try { + var s = read(); + var resolved = resolveTheme(s.theme); + var root = document.documentElement; + root.dataset.theme = resolved; + root.dataset.accent = s.accent; + root.style.setProperty('--border-boost', String(s.borderBoost)); + root.style.setProperty('--glow', String(s.glow)); + root.style.setProperty('--contrast', String(s.contrast)); + root.style.setProperty('--ui-font', "'" + s.uiFont + "'"); + root.style.setProperty('--mono-font', "'" + s.monoFont + "'"); + root.style.setProperty('--type-scale', String(s.typeScale)); + root.classList.toggle('dark', resolved !== 'light'); + } catch (e) { /* ignore */ } })(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 164b4e67..b5425cb9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ function AppContent() { if (appStatus === 'loading') { return ( -
+
Loading...
); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 86c3363c..80a321bc 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -13,7 +13,8 @@ import { useStackListState } from './EditorLayout/hooks/useStackListState'; import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState'; import { useOverlayState } from './EditorLayout/hooks/useOverlayState'; import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions'; -import { useTheme } from './EditorLayout/hooks/useTheme'; +import { useTheme } from '@/hooks/use-theme'; +import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch'; import { useNotifications } from './EditorLayout/hooks/useNotifications'; import { useContainerStats } from './EditorLayout/hooks/useContainerStats'; import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu'; @@ -223,7 +224,7 @@ export default function EditorLayout() { const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null; const stackName = selectedFile || ''; - const { theme, setTheme, isDarkMode } = useTheme(); + const { isDarkMode } = useTheme(); // Track the last "committed" node id so the node-switch dirty guard can // detect an actual switch (vs the initial mount or an internal revert). @@ -347,7 +348,7 @@ export default function EditorLayout() { return ( -
+
} + themeSwitch={} notifications={ handleOpenSettings('account')} /> } diff --git a/frontend/src/components/EditorLayout/hooks/useTheme.test.ts b/frontend/src/components/EditorLayout/hooks/useTheme.test.ts deleted file mode 100644 index 0a2213cb..00000000 --- a/frontend/src/components/EditorLayout/hooks/useTheme.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { useTheme } from './useTheme'; - -const mockMatchMedia = (matches: boolean) => { - const listeners: Array<(e: MediaQueryListEvent) => void> = []; - return { - matches, - addEventListener: vi.fn((_: string, cb: (e: MediaQueryListEvent) => void) => { listeners.push(cb); }), - removeEventListener: vi.fn(), - _trigger: (m: boolean) => listeners.forEach(cb => cb({ matches: m } as MediaQueryListEvent)), - }; -}; - -describe('useTheme', () => { - let mq: ReturnType; - - beforeEach(() => { - localStorage.clear(); - mq = mockMatchMedia(false); - vi.stubGlobal('matchMedia', vi.fn(() => mq)); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - document.documentElement.classList.remove('dark'); - }); - - it('defaults to dark theme when no localStorage entry', () => { - const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('dark'); - expect(result.current.isDarkMode).toBe(true); - }); - - it('reads persisted theme from localStorage', () => { - localStorage.setItem('sencho-theme', 'light'); - const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('light'); - expect(result.current.isDarkMode).toBe(false); - }); - - it('setTheme persists to localStorage and updates isDarkMode', () => { - const { result } = renderHook(() => useTheme()); - act(() => result.current.setTheme('light')); - expect(result.current.theme).toBe('light'); - expect(result.current.isDarkMode).toBe(false); - expect(localStorage.getItem('sencho-theme')).toBe('light'); - }); - - it('auto theme tracks system preference', () => { - mq = mockMatchMedia(true); - vi.stubGlobal('matchMedia', vi.fn(() => mq)); - localStorage.setItem('sencho-theme', 'auto'); - const { result } = renderHook(() => useTheme()); - expect(result.current.isDarkMode).toBe(true); - }); - - it('applies dark class to documentElement when isDarkMode', () => { - renderHook(() => useTheme()); - expect(document.documentElement.classList.contains('dark')).toBe(true); - }); - - it('removes dark class when switching to light', () => { - const { result } = renderHook(() => useTheme()); - act(() => result.current.setTheme('light')); - expect(document.documentElement.classList.contains('dark')).toBe(false); - }); -}); diff --git a/frontend/src/components/EditorLayout/hooks/useTheme.ts b/frontend/src/components/EditorLayout/hooks/useTheme.ts deleted file mode 100644 index 38a5ae2d..00000000 --- a/frontend/src/components/EditorLayout/hooks/useTheme.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect, useState } from 'react'; - -type Theme = 'light' | 'dark' | 'auto'; - -function readTheme(): Theme { - if (typeof window === 'undefined') return 'dark'; - const saved = localStorage.getItem('sencho-theme') as Theme | null; - if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved; - return 'dark'; -} - -export function useTheme() { - const [theme, setThemeState] = useState(readTheme); - const [systemDark, setSystemDark] = useState(() => - typeof window !== 'undefined' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : false, - ); - - const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); - - useEffect(() => { - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches); - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - }, []); - - useEffect(() => { - document.documentElement.classList.toggle('dark', isDarkMode); - try { localStorage.setItem('sencho-theme', theme); } catch { /* ignore */ } - }, [isDarkMode, theme]); - - const setTheme = (next: Theme) => setThemeState(next); - - return { theme, setTheme, isDarkMode } as const; -} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 19d332a4..ee7694a5 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -18,6 +18,7 @@ interface TopBarProps { mobileNavOpen: boolean; onMobileNavOpenChange: (open: boolean) => void; search?: ReactNode; + themeSwitch?: ReactNode; notifications: ReactNode; userMenu: ReactNode; } @@ -29,6 +30,7 @@ export function TopBar({ mobileNavOpen, onMobileNavOpenChange, search, + themeSwitch, notifications, userMenu, }: TopBarProps) { @@ -76,6 +78,7 @@ export function TopBar({ {/* RIGHT ZONE: Utilities + identity pin */}
{search} + {themeSwitch} {notifications} {userMenu} diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index c7203153..29eac15b 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -3,9 +3,6 @@ import { Settings, LogOut, ExternalLink, - Monitor, - Sun, - Moon, User, Loader2, BookOpen, @@ -15,7 +12,6 @@ import { import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { SegmentedControl } from '@/components/ui/segmented-control'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { apiFetch } from '@/lib/api'; @@ -23,20 +19,10 @@ import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import { TierBadge } from './TierBadge'; -type Theme = 'light' | 'dark' | 'auto'; - interface UserProfileDropdownProps { - theme: Theme; - setTheme: (theme: Theme) => void; onOpenSettings: () => void; } -const THEME_OPTIONS = [ - { value: 'auto' as const, label: 'Auto', icon: Monitor }, - { value: 'light' as const, label: 'Light', icon: Sun }, - { value: 'dark' as const, label: 'Dark', icon: Moon }, -]; - function getInitials(username: string | undefined): string { if (!username) return ''; const trimmed = username.trim(); @@ -48,7 +34,7 @@ function getInitials(username: string | undefined): string { return trimmed.slice(0, 2).toUpperCase(); } -export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { +export function UserProfileDropdown({ onOpenSettings }: UserProfileDropdownProps) { const { logout, user, isAdmin } = useAuth(); const { license } = useLicense(); const [billingLoading, setBillingLoading] = useState(false); @@ -172,20 +158,6 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro />
- {/* Appearance */} -
- - Appearance - - -
- {/* Logout */}
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/theme/ThemePreview.tsx b/frontend/src/components/theme/ThemePreview.tsx new file mode 100644 index 00000000..50116184 --- /dev/null +++ b/frontend/src/components/theme/ThemePreview.tsx @@ -0,0 +1,77 @@ +import { cn } from '@/lib/utils'; + +const LADDER: { bg: string; label: string }[] = [ + { bg: 'bg-background', label: 'page' }, + { bg: 'bg-card', label: 'card' }, + { bg: 'bg-band', label: 'band' }, + { bg: 'bg-well', label: 'well' }, +]; + +/** + * Compact, self-contained preview of the current theme. It consumes only global + * tokens, so it (and the rest of the app) re-render live as the mode, accent, or + * fine-tune knobs change. A concentrated view of surfaces, borders, ink tiers, + * status, and the accent without scanning the whole page. + */ +export function ThemePreview() { + return ( +
+
+
+ {/* Mini masthead: rail + display state word + mono kicker + status dots */} +
+
+
+ fleet · preview +
+
+ Healthy +
+
+ 6 nodes · last sync 9s +
+
+
+ + + + +
+
+ + {/* Ink tiers */} +
+ Value + Title + Subtitle + Icon +
+ + {/* Surface ladder */} +
+ {LADDER.map(({ bg, label }) => ( +
+
+ + {label} + +
+ ))} +
+ + {/* Brand progress + key affordances */} +
+
+
+
+ + Outline + + + Primary + +
+
+
+ ); +} diff --git a/frontend/src/components/theme/ThemeQuickSwitch.tsx b/frontend/src/components/theme/ThemeQuickSwitch.tsx new file mode 100644 index 00000000..3277c801 --- /dev/null +++ b/frontend/src/components/theme/ThemeQuickSwitch.tsx @@ -0,0 +1,124 @@ +import { Palette } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { SegmentedControl } from '@/components/ui/segmented-control'; +import { AccentPicker } from './AccentPicker'; +import { TypeChips } from './TypeChips'; +import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS, SIZE_OPTIONS, sizeIdForScale } from './typeOptions'; +import { useTheme, THEME_MODES, THEME_MODE_OPTIONS, ACCENTS, TYPE_SIZES } from '@/hooks/use-theme'; + +/** + * Topbar quick theme switch (between search and notifications). Opens a small + * popover to flip the mode and accent; the fine-tune sliders live in + * Settings → Appearance. Reads/writes the shared theme store directly. Mirrors + * the masthead + bordered-section chrome of the notification and profile panels. + */ +export function ThemeQuickSwitch() { + const { + theme, accent, uiFont, monoFont, typeScale, + setTheme, setAccent, setUiFont, setMonoFont, setTypeScale, + } = useTheme(); + const themeLabel = THEME_MODES.find((m) => m.id === theme)?.label ?? 'Dim'; + const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan'; + const onSize = (id: string) => { + const match = TYPE_SIZES.find((s) => s.id === id); + if (match) setTypeScale(match.scale); + }; + + return ( + + + + + + {/* Masthead */} +
+
+
+
+ + Theme + + + {themeLabel} + +
+
+ + {/* Mode */} +
+ + Mode + + +
+ + {/* Accent */} +
+
+ + Accent + + + {accentLabel} + +
+ +
+ + {/* Type */} +
+ + Type + +
+ + Interface + + +
+
+ + Data + + +
+
+ + Size + + +
+
+ + {/* Footer hint */} +
+

+ Saved to this browser · fine-tune borders & glow in Settings +

+
+ + + ); +} diff --git a/frontend/src/components/theme/TypeChips.tsx b/frontend/src/components/theme/TypeChips.tsx new file mode 100644 index 00000000..eb5e39a1 --- /dev/null +++ b/frontend/src/components/theme/TypeChips.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/lib/utils'; +import type { TypeChipOption } from './typeOptions'; +import { useRovingRadio } from './useRovingRadio'; + +interface TypeChipsProps { + value: T; + options: TypeChipOption[]; + onChange: (id: T) => void; + columns?: 3 | 4; + ariaLabel?: string; + className?: string; +} + +/** + * A row of preview chips for type personalization: each chip renders a sample + * glyph in the option's own face (or size) above its name, with the selected + * chip highlighted in the accent. Shared by the topbar Type section and + * Settings → Appearance → Typography. Mirrors AccentPicker's selected treatment. + */ +export function TypeChips({ + value, + options, + onChange, + columns = 3, + ariaLabel, + className, +}: TypeChipsProps) { + const itemProps = useRovingRadio(options.map((o) => o.id), value, onChange); + return ( +
+ {options.map((o, i) => { + const active = o.id === value; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/theme/typeOptions.test.ts b/frontend/src/components/theme/typeOptions.test.ts new file mode 100644 index 00000000..d11f00ba --- /dev/null +++ b/frontend/src/components/theme/typeOptions.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { sizeIdForScale, SIZE_OPTIONS, UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from './typeOptions'; + +describe('sizeIdForScale', () => { + it('maps each exact preset scale to its id', () => { + expect(sizeIdForScale(0.92)).toBe('S'); + expect(sizeIdForScale(1.0)).toBe('M'); + expect(sizeIdForScale(1.08)).toBe('L'); + expect(sizeIdForScale(1.16)).toBe('XL'); + }); + + it('returns "" for off-preset scales so the popover shows no false selection', () => { + expect(sizeIdForScale(0.96)).toBe(''); + expect(sizeIdForScale(1.04)).toBe(''); + expect(sizeIdForScale(1.2)).toBe(''); + }); + + it('tolerates tiny floating-point drift within epsilon', () => { + expect(sizeIdForScale(1.0 + 0.0005)).toBe('M'); + expect(sizeIdForScale(1.08 - 0.0005)).toBe('L'); + }); +}); + +describe('type option sets', () => { + it('exposes 3 interface faces, 3 data faces, and the four size presets', () => { + expect(UI_FONT_OPTIONS).toHaveLength(3); + expect(MONO_FONT_OPTIONS).toHaveLength(3); + expect(SIZE_OPTIONS.map((o) => o.id)).toEqual(['S', 'M', 'L', 'XL']); + }); +}); diff --git a/frontend/src/components/theme/typeOptions.ts b/frontend/src/components/theme/typeOptions.ts new file mode 100644 index 00000000..846a7daf --- /dev/null +++ b/frontend/src/components/theme/typeOptions.ts @@ -0,0 +1,38 @@ +import type { CSSProperties } from 'react'; +import { UI_FONTS, MONO_FONTS, TYPE_SIZES, type UiFont, type MonoFont } from '@/hooks/use-theme'; + +export interface TypeChipOption { + id: T; + name: string; + preview: string; + previewStyle?: CSSProperties; +} + +// Shared option sets so the popover and the settings section stay identical. +export const UI_FONT_OPTIONS: TypeChipOption[] = UI_FONTS.map((f) => ({ + id: f.id, + name: f.name, + preview: 'Ag', + previewStyle: { fontFamily: `'${f.id}', sans-serif`, fontSize: '17px' }, +})); + +export const MONO_FONT_OPTIONS: TypeChipOption[] = MONO_FONTS.map((f) => ({ + id: f.id, + name: f.name, + preview: '01', + previewStyle: { fontFamily: `'${f.id}', monospace`, fontSize: '16px' }, +})); + +export const SIZE_OPTIONS: TypeChipOption[] = TYPE_SIZES.map((s) => ({ + id: s.id, + name: s.id, + preview: 'A', + previewStyle: { fontSize: `${s.px}px` }, +})); + +/** Map the stored numeric --type-scale to its preset id, or '' when it sits + * between presets (the Settings slider can land on off-preset values) so the + * popover chips show no false selection. */ +export function sizeIdForScale(scale: number): string { + return TYPE_SIZES.find((s) => Math.abs(s.scale - scale) < 0.001)?.id ?? ''; +} diff --git a/frontend/src/components/theme/useRovingRadio.ts b/frontend/src/components/theme/useRovingRadio.ts new file mode 100644 index 00000000..41fe42cc --- /dev/null +++ b/frontend/src/components/theme/useRovingRadio.ts @@ -0,0 +1,53 @@ +import { useRef, type KeyboardEvent } from 'react'; + +/** + * Roving-tabindex keyboard model for a single-select radio group rendered as a + * grid of buttons (AccentPicker, TypeChips). Exactly one item is tabbable (the + * selected one, or the first when nothing matches), and Arrow / Home / End move + * focus and selection together, matching the WAI-ARIA radiogroup pattern (and + * the existing SegmentedControl). Returns a props factory for each item. + */ +export function useRovingRadio(values: T[], current: T, onChange: (value: T) => void) { + const refs = useRef<(HTMLButtonElement | null)[]>([]); + const selectedIndex = values.indexOf(current); + const tabbableIndex = selectedIndex >= 0 ? selectedIndex : 0; + + const move = (index: number) => { + const n = values.length; + if (n === 0) return; + const i = ((index % n) + n) % n; + refs.current[i]?.focus(); + onChange(values[i]); + }; + + const onKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault(); + move(index + 1); + break; + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault(); + move(index - 1); + break; + case 'Home': + e.preventDefault(); + move(0); + break; + case 'End': + e.preventDefault(); + move(values.length - 1); + break; + } + }; + + return (index: number) => ({ + ref: (el: HTMLButtonElement | null) => { + refs.current[index] = el; + }, + tabIndex: index === tabbableIndex ? 0 : -1, + onKeyDown: (e: KeyboardEvent) => onKeyDown(e, index), + }); +} diff --git a/frontend/src/components/ui/segmented-control.tsx b/frontend/src/components/ui/segmented-control.tsx index b959b8be..920a784c 100644 --- a/frontend/src/components/ui/segmented-control.tsx +++ b/frontend/src/components/ui/segmented-control.tsx @@ -16,6 +16,7 @@ interface SegmentedControlProps { onChange: (next: T) => void; ariaLabel?: string; iconOnly?: boolean; + fullWidth?: boolean; className?: string; } @@ -25,6 +26,7 @@ export function SegmentedControl({ onChange, ariaLabel, iconOnly, + fullWidth, className, }: SegmentedControlProps) { const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]); @@ -61,6 +63,7 @@ export function SegmentedControl({ aria-label={ariaLabel} className={cn( 'inline-flex items-center rounded-md border border-card-border bg-card p-0.5', + fullWidth && 'flex w-full', className, )} > @@ -89,6 +92,7 @@ export function SegmentedControl({ onKeyDown={(e) => handleKeyDown(e, index)} className={cn( 'flex items-center gap-1.5 rounded px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50', + fullWidth && 'flex-1 justify-center', active ? 'bg-brand/10 text-brand' : 'text-stat-subtitle hover:text-stat-value', diff --git a/frontend/src/hooks/use-theme.read.test.ts b/frontend/src/hooks/use-theme.read.test.ts new file mode 100644 index 00000000..e3c99589 --- /dev/null +++ b/frontend/src/hooks/use-theme.read.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// The store reads localStorage once at module import, so the read/validate/migrate +// path can only be exercised by re-importing the module after seeding storage. +// vi.resetModules() + a dynamic import gives a fresh store per test. + +const KEY = 'sencho.appearance.theme'; + +describe('use-theme read / validate / migrate', () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + const root = document.documentElement; + root.removeAttribute('style'); + root.removeAttribute('data-theme'); + root.removeAttribute('data-accent'); + root.classList.remove('dark'); + }); + + async function applyStored(): Promise { + const mod = await import('./use-theme'); + mod.initializeTheme(); + return document.documentElement; + } + + it('clamps out-of-range knobs and rejects non-finite / non-number values', async () => { + localStorage.setItem(KEY, JSON.stringify({ glow: 999, contrast: 'nope', typeScale: 0, borderBoost: NaN })); + const root = await applyStored(); + expect(root.style.getPropertyValue('--glow')).toBe('0.4'); // 999 clamped to max + expect(root.style.getPropertyValue('--contrast')).toBe('0'); // 'nope' -> default + expect(root.style.getPropertyValue('--type-scale')).toBe('0.88'); // 0 clamped to min + expect(root.style.getPropertyValue('--border-boost')).toBe('0'); // NaN serializes to null -> default + }); + + it('falls back to defaults for unknown mode / accent (e.g. the removed teal)', async () => { + localStorage.setItem(KEY, JSON.stringify({ theme: 'banana', accent: 'teal' })); + const root = await applyStored(); + expect(root.dataset.theme).toBe('dim'); + expect(root.dataset.accent).toBe('cyan'); + }); + + it('migrates the legacy sencho-theme key (dark -> dim)', async () => { + localStorage.setItem('sencho-theme', 'dark'); + const root = await applyStored(); + expect(root.dataset.theme).toBe('dim'); + expect(root.classList.contains('dark')).toBe(true); + }); + + it('migrates the legacy light value through to the light theme', async () => { + localStorage.setItem('sencho-theme', 'light'); + const root = await applyStored(); + expect(root.dataset.theme).toBe('light'); + expect(root.classList.contains('dark')).toBe(false); + }); +}); diff --git a/frontend/src/hooks/use-theme.test.ts b/frontend/src/hooks/use-theme.test.ts new file mode 100644 index 00000000..43210823 --- /dev/null +++ b/frontend/src/hooks/use-theme.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTheme, initializeTheme, THEME_MODES, ACCENTS, UI_FONTS, MONO_FONTS, TYPE_SIZES } from './use-theme'; + +const STORAGE_KEY = 'sencho.appearance.theme'; + +function readBlob(): Record { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); +} + +describe('useTheme', () => { + beforeEach(() => { + // Sync to the current store state (the pre-paint script does this + // in the browser; tests have no pre-paint). + initializeTheme(); + }); + + afterEach(() => { + // Reset the shared module store so tests stay order-independent. + const { result } = renderHook(() => useTheme()); + act(() => { + result.current.setTheme('dim'); + result.current.setAccent('cyan'); + result.current.setBorderBoost(0); + result.current.setGlow(0.16); + result.current.setContrast(0); + result.current.setUiFont('Geist'); + result.current.setMonoFont('Geist Mono'); + result.current.setTypeScale(1); + }); + }); + + it('exposes four modes and the distinct eight accents (no teal/indigo)', () => { + expect(THEME_MODES.map((m) => m.id)).toEqual(['dim', 'oled', 'light', 'auto']); + expect(ACCENTS).toHaveLength(8); + expect(ACCENTS.map((a) => a.id)).toEqual( + expect.arrayContaining(['cyan', 'blue', 'violet', 'magenta', 'orange', 'amber', 'lime', 'steel']), + ); + expect(ACCENTS.map((a) => a.id)).not.toContain('teal'); + expect(ACCENTS.map((a) => a.id)).not.toContain('indigo'); + }); + + it('defaults to Dim with the dark class applied', () => { + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dim'); + expect(result.current.accent).toBe('cyan'); + expect(result.current.isDarkMode).toBe(true); + expect(document.documentElement.dataset.theme).toBe('dim'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('light mode drops the dark class and persists', () => { + const { result } = renderHook(() => useTheme()); + act(() => result.current.setTheme('light')); + expect(result.current.isDarkMode).toBe(false); + expect(document.documentElement.dataset.theme).toBe('light'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + expect(readBlob().theme).toBe('light'); + }); + + it('OLED keeps the dark class', () => { + const { result } = renderHook(() => useTheme()); + act(() => result.current.setTheme('oled')); + expect(result.current.isDarkMode).toBe(true); + expect(document.documentElement.dataset.theme).toBe('oled'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('applies accent and the three knobs to and persists them', () => { + const { result } = renderHook(() => useTheme()); + act(() => { + result.current.setAccent('violet'); + result.current.setBorderBoost(0.05); + result.current.setGlow(0.3); + result.current.setContrast(0.6); + }); + expect(document.documentElement.dataset.accent).toBe('violet'); + expect(document.documentElement.style.getPropertyValue('--border-boost')).toBe('0.05'); + expect(document.documentElement.style.getPropertyValue('--glow')).toBe('0.3'); + expect(document.documentElement.style.getPropertyValue('--contrast')).toBe('0.6'); + const blob = readBlob(); + expect(blob.accent).toBe('violet'); + expect(blob.borderBoost).toBe(0.05); + expect(blob.glow).toBe(0.3); + expect(blob.contrast).toBe(0.6); + }); + + it('exposes 3 interface faces, 3 data faces, and 4 size presets with Geist defaults', () => { + expect(UI_FONTS.map((f) => f.id)).toEqual(['Geist', 'IBM Plex Sans', 'Hanken Grotesk']); + expect(MONO_FONTS.map((f) => f.id)).toEqual(['Geist Mono', 'IBM Plex Mono', 'Fira Code']); + expect(TYPE_SIZES.map((s) => s.id)).toEqual(['S', 'M', 'L', 'XL']); + const { result } = renderHook(() => useTheme()); + expect(result.current.uiFont).toBe('Geist'); + expect(result.current.monoFont).toBe('Geist Mono'); + expect(result.current.typeScale).toBe(1); + }); + + it('applies font faces and text scale to and persists them', () => { + const { result } = renderHook(() => useTheme()); + act(() => { + result.current.setUiFont('IBM Plex Sans'); + result.current.setMonoFont('Fira Code'); + result.current.setTypeScale(1.16); + }); + expect(document.documentElement.style.getPropertyValue('--ui-font')).toBe("'IBM Plex Sans'"); + expect(document.documentElement.style.getPropertyValue('--mono-font')).toBe("'Fira Code'"); + expect(document.documentElement.style.getPropertyValue('--type-scale')).toBe('1.16'); + const blob = readBlob(); + expect(blob.uiFont).toBe('IBM Plex Sans'); + expect(blob.monoFont).toBe('Fira Code'); + expect(blob.typeScale).toBe(1.16); + }); + + it('shares state across two consumers (one store)', () => { + const a = renderHook(() => useTheme()); + const b = renderHook(() => useTheme()); + act(() => a.result.current.setAccent('lime')); + expect(a.result.current.accent).toBe('lime'); + expect(b.result.current.accent).toBe('lime'); + }); +}); diff --git a/frontend/src/hooks/use-theme.ts b/frontend/src/hooks/use-theme.ts new file mode 100644 index 00000000..3fe84243 --- /dev/null +++ b/frontend/src/hooks/use-theme.ts @@ -0,0 +1,285 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import { Moon, Zap, Sun, Monitor } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +// Shared theme store. Two live consumers (the topbar quick switch and the +// Settings → Appearance section) must reflect each other instantly, so the +// state lives in a module-level external store exposed via useSyncExternalStore +// rather than per-component state. The apply-to- logic mirrors +// public/theme-init.js (the pre-paint script); keep the two in sync. + +export type ThemeMode = 'dim' | 'oled' | 'light' | 'auto'; +export type ResolvedTheme = 'dim' | 'oled' | 'light'; +export type AccentId = + | 'cyan' | 'blue' | 'violet' | 'magenta' + | 'orange' | 'amber' | 'lime' | 'steel'; + +// Display (Instrument Serif) is the locked signature face. Only the interface +// (sans) and data (mono) faces are user-swappable, plus a root text-size scale. +export type UiFont = 'Geist' | 'IBM Plex Sans' | 'Hanken Grotesk'; +export type MonoFont = 'Geist Mono' | 'IBM Plex Mono' | 'Fira Code'; + +export interface ThemeState { + theme: ThemeMode; + accent: AccentId; + borderBoost: number; + glow: number; + contrast: number; + uiFont: UiFont; + monoFont: MonoFont; + typeScale: number; +} + +export const THEME_MODES: { id: ThemeMode; label: string; icon: LucideIcon }[] = [ + { id: 'dim', label: 'Dim', icon: Moon }, + { id: 'oled', label: 'OLED', icon: Zap }, + { id: 'light', label: 'Light', icon: Sun }, + { id: 'auto', label: 'Auto', icon: Monitor }, +]; + +// Pre-mapped for SegmentedControl so the popover and the settings section stay identical. +export const THEME_MODE_OPTIONS = THEME_MODES.map((m) => ({ value: m.id, label: m.label, icon: m.icon })); + +// h/c match the [data-accent] rules in index.css; lightness comes from the theme. +// A tighter, well-spaced 8-hue wheel (ordered for the 2x4 picker). Cyan default. +export const ACCENTS: { id: AccentId; label: string; h: number; c: number }[] = [ + { id: 'orange', label: 'Orange', h: 42, c: 0.165 }, + { id: 'amber', label: 'Amber', h: 88, c: 0.165 }, + { id: 'lime', label: 'Lime', h: 132, c: 0.195 }, + { id: 'cyan', label: 'Cyan', h: 196, c: 0.130 }, + { id: 'blue', label: 'Blue', h: 252, c: 0.165 }, + { id: 'violet', label: 'Violet', h: 298, c: 0.190 }, + { id: 'magenta', label: 'Magenta', h: 344, c: 0.180 }, + { id: 'steel', label: 'Steel', h: 250, c: 0.040 }, +]; + +// Personalization-knob bounds (also enforced by the sliders). +export const CONTRAST = { min: -0.6, max: 1.2, step: 0.05, default: 0 } as const; +export const BORDER_BOOST = { min: -0.06, max: 0.12, step: 0.01, default: 0 } as const; +export const GLOW = { min: 0, max: 0.4, step: 0.01, default: 0.16 } as const; +// Continuous text-size bounds for the Settings slider (the popover uses presets). +export const TYPE_SCALE = { min: 0.88, max: 1.2, step: 0.02, default: 1 } as const; + +// Swappable faces (the family string is the CSS value; name is the display label). +export const UI_FONTS: { id: UiFont; name: string }[] = [ + { id: 'Geist', name: 'Geist' }, + { id: 'IBM Plex Sans', name: 'IBM Plex' }, + { id: 'Hanken Grotesk', name: 'Hanken' }, +]; +export const MONO_FONTS: { id: MonoFont; name: string }[] = [ + { id: 'Geist Mono', name: 'Geist Mono' }, + { id: 'IBM Plex Mono', name: 'Plex Mono' }, + { id: 'Fira Code', name: 'Fira Code' }, +]; +// Text-size presets: id label + the --type-scale multiplier + a preview px. +export const TYPE_SIZES: { id: string; scale: number; px: number }[] = [ + { id: 'S', scale: 0.92, px: 11 }, + { id: 'M', scale: 1.0, px: 13 }, + { id: 'L', scale: 1.08, px: 15 }, + { id: 'XL', scale: 1.16, px: 17 }, +]; + +const STORAGE_KEY = 'sencho.appearance.theme'; +const LEGACY_KEY = 'sencho-theme'; +const DEFAULT_STATE: ThemeState = { + theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0, + uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1, +}; + +const MODE_IDS = new Set(THEME_MODES.map((m) => m.id)); +const ACCENT_IDS = new Set(ACCENTS.map((a) => a.id)); +const UI_FONT_IDS = new Set(UI_FONTS.map((f) => f.id)); +const MONO_FONT_IDS = new Set(MONO_FONTS.map((f) => f.id)); + +function isMode(v: unknown): v is ThemeMode { + return typeof v === 'string' && MODE_IDS.has(v); +} +function isAccent(v: unknown): v is AccentId { + return typeof v === 'string' && ACCENT_IDS.has(v); +} +function isUiFont(v: unknown): v is UiFont { + return typeof v === 'string' && UI_FONT_IDS.has(v); +} +function isMonoFont(v: unknown): v is MonoFont { + return typeof v === 'string' && MONO_FONT_IDS.has(v); +} +// Numeric knobs: a persisted value must be finite and in range, otherwise fall +// back to the default (a NaN/Infinity/out-of-range value would silently no-op +// in CSS, which is harder to diagnose than a reset to default). +function readNumber(value: unknown, bounds: { min: number; max: number; default: number }): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return bounds.default; + return Math.min(bounds.max, Math.max(bounds.min, value)); +} + +function readStored(): ThemeState { + if (typeof window === 'undefined') return { ...DEFAULT_STATE }; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) { + const p = JSON.parse(raw) as Partial | null; + if (p && typeof p === 'object') { + return { + theme: isMode(p.theme) ? p.theme : DEFAULT_STATE.theme, + accent: isAccent(p.accent) ? p.accent : DEFAULT_STATE.accent, + borderBoost: readNumber(p.borderBoost, BORDER_BOOST), + glow: readNumber(p.glow, GLOW), + contrast: readNumber(p.contrast, CONTRAST), + uiFont: isUiFont(p.uiFont) ? p.uiFont : DEFAULT_STATE.uiFont, + monoFont: isMonoFont(p.monoFont) ? p.monoFont : DEFAULT_STATE.monoFont, + typeScale: readNumber(p.typeScale, TYPE_SCALE), + }; + } + } + // Legacy migration: the old key held only the mode string (dark → dim). + const legacy = window.localStorage.getItem(LEGACY_KEY); + if (legacy === 'light' || legacy === 'auto') return { ...DEFAULT_STATE, theme: legacy }; + if (legacy === 'dark') return { ...DEFAULT_STATE, theme: 'dim' }; + } catch { + // ignore; localStorage may be unavailable (private mode, quota) + } + return { ...DEFAULT_STATE }; +} + +function systemPrefersDark(): boolean { + return ( + typeof window !== 'undefined' && + !!window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches + ); +} + +function resolveWith(theme: ThemeMode, systemDark: boolean): ResolvedTheme { + if (theme === 'auto') return systemDark ? 'dim' : 'light'; + return theme; +} + +/** Resolve a mode to a concrete theme using the live OS preference. */ +export function resolveTheme(theme: ThemeMode): ResolvedTheme { + return resolveWith(theme, systemPrefersDark()); +} + +function applyToDom(s: ThemeState, systemDark: boolean) { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const resolved = resolveWith(s.theme, systemDark); + root.dataset.theme = resolved; + root.dataset.accent = s.accent; + root.style.setProperty('--border-boost', String(s.borderBoost)); + root.style.setProperty('--glow', String(s.glow)); + root.style.setProperty('--contrast', String(s.contrast)); + root.style.setProperty('--ui-font', `'${s.uiFont}'`); + root.style.setProperty('--mono-font', `'${s.monoFont}'`); + root.style.setProperty('--type-scale', String(s.typeScale)); + root.classList.toggle('dark', resolved !== 'light'); +} + +// ── store ────────────────────────────────────────────────────────────────── +export interface ThemeSnapshot extends ThemeState { + systemDark: boolean; +} + +let persisted: ThemeState = readStored(); +let systemDark = systemPrefersDark(); +let snapshot: ThemeSnapshot = { ...persisted, systemDark }; +const listeners = new Set<() => void>(); + +function rebuild() { + snapshot = { ...persisted, systemDark }; +} +function emit() { + for (const l of listeners) l(); +} + +function sameState(a: ThemeState, b: ThemeState): boolean { + return a.theme === b.theme && a.accent === b.accent + && a.borderBoost === b.borderBoost && a.glow === b.glow && a.contrast === b.contrast + && a.uiFont === b.uiFont && a.monoFont === b.monoFont && a.typeScale === b.typeScale; +} + +function setState(patch: Partial) { + const next: ThemeState = { ...persisted, ...patch }; + if (sameState(next, persisted)) return; + persisted = next; + rebuild(); + applyToDom(persisted, systemDark); + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted)); + } catch { + // ignore + } + emit(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ThemeSnapshot { + return snapshot; +} + +if (typeof window !== 'undefined') { + // Cross-tab sync: another tab wrote a new look. + window.addEventListener('storage', (e) => { + if (e.key !== STORAGE_KEY) return; + const incoming = readStored(); + if (sameState(incoming, persisted)) return; + persisted = incoming; + rebuild(); + applyToDom(persisted, systemDark); + emit(); + }); + // Auto mode re-resolves live when the OS flips. + if (window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener('change', (e) => { + if (e.matches === systemDark) return; + systemDark = e.matches; + rebuild(); + if (persisted.theme === 'auto') applyToDom(persisted, systemDark); + emit(); + }); + } +} + +/** Re-assert the stored look on at startup (idempotent; the pre-paint + * script already applied it, this keeps the store and DOM in agreement). */ +export function initializeTheme() { + applyToDom(persisted, systemDark); +} + +export function useTheme() { + const s = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const setTheme = useCallback((theme: ThemeMode) => setState({ theme }), []); + const setAccent = useCallback((accent: AccentId) => setState({ accent }), []); + const setBorderBoost = useCallback((borderBoost: number) => setState({ borderBoost }), []); + const setGlow = useCallback((glow: number) => setState({ glow }), []); + const setContrast = useCallback((contrast: number) => setState({ contrast }), []); + const setUiFont = useCallback((uiFont: UiFont) => setState({ uiFont }), []); + const setMonoFont = useCallback((monoFont: MonoFont) => setState({ monoFont }), []); + const setTypeScale = useCallback((typeScale: number) => setState({ typeScale }), []); + const resolvedTheme = resolveWith(s.theme, s.systemDark); + return { + theme: s.theme, + accent: s.accent, + borderBoost: s.borderBoost, + glow: s.glow, + contrast: s.contrast, + uiFont: s.uiFont, + monoFont: s.monoFont, + typeScale: s.typeScale, + resolvedTheme, + isDarkMode: resolvedTheme !== 'light', + setTheme, + setAccent, + setBorderBoost, + setGlow, + setContrast, + setUiFont, + setMonoFont, + setTypeScale, + } as const; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 0ee7846a..b89cecb0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,225 +5,113 @@ @custom-variant dark (&:is(.dark *)); /* ───────────────────────────────────────────────────────────── - LIGHT THEME + THEME ENGINE + Theme is an attribute on (data-theme = dim · oled · light); + accent is a second attribute (data-accent = one of eight hues). + Surfaces, ink and borders derive from a handful of raw lightness + vars per theme, so the live knobs (--border-boost, --glow) modulate + every hairline and the page glow through calc(). The accent sets + --brand everywhere and tints the ambient glow. + + :root carries the shared + computed tokens AND the Dim defaults, so + a document with no data-theme renders Dim (the default theme). + [data-theme="oled"] and [data-theme="light"] override from there. ───────────────────────────────────────────────────────────── */ :root { - --background: oklch(0.97 0 0); - --foreground: oklch(0.15 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.15 0 0); - --popover: oklch(1 0 0 / 0.92); - --popover-foreground: oklch(0.15 0 0); - --primary: oklch(0.15 0 0); - --primary-foreground: oklch(1 0 0); - --secondary: oklch(0 0 0 / 0.05); - --secondary-foreground: oklch(0.15 0 0); - --muted: oklch(0 0 0 / 0.04); - --muted-foreground: oklch(0.50 0 0); - --accent: oklch(0 0 0 / 0.06); - --accent-foreground: oklch(0.15 0 0); - --destructive: oklch(0.63 0.18 20); - --destructive-foreground: oklch(1 0 0); - --border: oklch(0 0 0 / 0.10); - --input: oklch(0.97 0 0); - --ring: oklch(0.50 0.14 200); + color-scheme: dark; - /* Brand: technical cyan, the signature accent */ - --brand: oklch(0.50 0.14 200); - --brand-foreground: oklch(1 0 0); - --brand-muted: oklch(0.50 0.14 200 / 0.12); + /* Live knobs (overwritten on by the pre-paint script / useTheme) */ + --border-boost: 0; /* signed offset added to every border L */ + --glow: 0.16; /* ambient top-glow alpha */ + --contrast: 0; /* master contrast: spreads page, ink and borders, signed by --ink-dir */ - /* Glass: frosted surface system */ - --glass: oklch(1 0 0); - --glass-border: oklch(0 0 0 / 0.10); - --glass-highlight: oklch(0 0 0 / 0.06); + /* Accent default: cyan (overridden by [data-accent]) */ + --accent-h: 196; + --accent-c: 0.130; - /* Semantic status colors */ - --success: oklch(0.65 0.16 155); - --success-foreground: oklch(1 0 0); - --success-muted: oklch(0.65 0.16 155 / 0.12); - --warning: oklch(0.75 0.14 75); - --warning-foreground: oklch(0.20 0 0); - --warning-muted: oklch(0.75 0.14 75 / 0.12); - /* --info: TOAST USE ONLY. Not part of the §17 five-slot working palette. - New components must consume --brand, --success, --warning, --destructive, - or surface/ink tokens instead. */ - --info: oklch(0.62 0.14 250); - --info-foreground: oklch(1 0 0); - --info-muted: oklch(0.62 0.14 250 / 0.12); - --update: oklch(0.62 0.22 320); - --update-foreground: oklch(1 0 0); - --update-muted: oklch(0.62 0.22 320 / 0.12); - --destructive-muted: oklch(0.63 0.18 20 / 0.12); + /* ---- Computed surfaces (flat neutral ladder per theme). The page tone + spreads away from the cards as --contrast rises; clamp keeps it in gamut + and always a hair below --card so page/card separation never collapses + (matters at high contrast in Light, where --card is pure white). ---- */ + --background: oklch(clamp(0, var(--bg) - var(--ink-dir) * var(--contrast) * 0.03, var(--card-l) - 0.012) 0 0); + --card: oklch(var(--card-l) 0 0); + --band: oklch(var(--band-l) 0 0); + --well: oklch(var(--well-l) 0 0); + + /* ---- Computed borders (opaque grey, directionally lit). --border-boost and + --contrast both lift every hairline through one expression; clamp keeps + them in gamut and below pure white so the lit edge never merges into a + white card in Light at the low end of the knobs. ---- */ + --card-border: oklch(clamp(0, var(--bb) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0); + --card-border-top: oklch(clamp(0, var(--bt) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0); + --card-border-hover: oklch(clamp(0, var(--bh) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0); + --hairline: oklch(clamp(0, var(--hl) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0); + --border: var(--card-border); + --sidebar-border: var(--card-border); + + /* ---- Computed ink tiers. --contrast pushes each tier toward its extreme so + secondary text crisps up as it rises; clamp keeps every tier in gamut. ---- */ + --stat-value: oklch(clamp(0, var(--val) + var(--ink-dir) * var(--contrast) * 0.04, 1) 0 0); + --stat-title: oklch(clamp(0, var(--title) + var(--ink-dir) * var(--contrast) * 0.055, 1) 0 0); + --stat-subtitle: oklch(clamp(0, var(--sub) + var(--ink-dir) * var(--contrast) * 0.07, 1) 0 0); + --stat-icon: oklch(clamp(0, var(--icon) + var(--ink-dir) * var(--contrast) * 0.06, 1) 0 0); + + /* ---- Computed brand (the accent is the one data color) ---- */ + --brand: oklch(var(--accent-l) var(--accent-c) var(--accent-h)); + --brand-foreground: var(--brand-fg); + --brand-muted: oklch(var(--accent-l) var(--accent-c) var(--accent-h) / 0.16); + --ring: var(--brand); + --sidebar-ring: var(--brand); + + /* Glass surface = the card tone; borders use the opaque hairline */ + --glass: var(--card); + + /* Ambient glow tracks the accent hue so the whole page tints with it */ + --glow-color: oklch(var(--glow-l) var(--glow-c) var(--accent-h) / var(--glow)); /* Charts: cyan leads as the primary data color, amber for peaks, rose for errors */ --chart-1: var(--brand); --chart-2: var(--warning); --chart-3: var(--destructive); - --chart-4: oklch(0.70 0 0); - --chart-5: oklch(0.45 0 0); /* Sparkline / data-line styling (centralized) */ --data-line-width: 1.25px; --data-peak: var(--warning); - /* Sidebar */ - --sidebar: oklch(0.98 0 0 / 0.80); - --sidebar-foreground: oklch(0.15 0 0); - --sidebar-primary: oklch(0.15 0 0); - --sidebar-primary-foreground: oklch(1 0 0); - --sidebar-accent: oklch(0 0 0 / 0.06); - --sidebar-accent-foreground: oklch(0.15 0 0); - --sidebar-border: oklch(0 0 0 / 0.10); - --sidebar-ring: oklch(0.50 0.14 200); - - /* Stat hierarchy: text brightness tiers */ - --stat-value: oklch(0.15 0 0); - --stat-title: oklch(0.40 0 0); - --stat-subtitle: oklch(0.50 0 0); - --stat-icon: oklch(0.60 0 0); - - /* Card borders: directional lighting */ - --card-border: oklch(0 0 0 / 0.08); - --card-border-top: oklch(0 0 0 / 0.12); - --card-border-hover: oklch(0 0 0 / 0.15); - - /* Label palette: ten raw hues preserved for existing assignments */ - --label-teal: oklch(0.55 0.12 192); - --label-teal-bg: oklch(0.55 0.12 192 / 0.12); - --label-blue: oklch(0.55 0.14 250); - --label-blue-bg: oklch(0.55 0.14 250 / 0.12); - --label-purple: oklch(0.55 0.14 300); - --label-purple-bg: oklch(0.55 0.14 300 / 0.12); - --label-rose: oklch(0.55 0.16 10); - --label-rose-bg: oklch(0.55 0.16 10 / 0.12); - --label-amber: oklch(0.60 0.16 60); - --label-amber-bg: oklch(0.60 0.16 60 / 0.12); - --label-green: oklch(0.55 0.15 150); - --label-green-bg: oklch(0.55 0.15 150 / 0.12); - --label-orange: oklch(0.58 0.16 45); - --label-orange-bg: oklch(0.58 0.16 45 / 0.12); - --label-pink: oklch(0.58 0.16 340); - --label-pink-bg: oklch(0.58 0.16 340 / 0.12); - --label-cyan: oklch(0.58 0.12 210); - --label-cyan-bg: oklch(0.58 0.12 210 / 0.12); - --label-slate: oklch(0.50 0.01 250); - --label-slate-bg: oklch(0.50 0.01 250 / 0.12); - - /* Semantic label roles: new labels should consume these */ - --label-env: var(--label-teal); - --label-env-bg: var(--label-teal-bg); - --label-role: var(--label-blue); - --label-role-bg: var(--label-blue-bg); - --label-owner: var(--label-green); - --label-owner-bg: var(--label-green-bg); - --label-risk: var(--label-rose); - --label-risk-bg: var(--label-rose-bg); - --label-custom: var(--label-amber); - --label-custom-bg: var(--label-amber-bg); - - /* Fleet Action card action-class chip / blast-dot colors. - Closed set per audit §18.3: destructive (rose) / transformative (purple) / maintenance (amber). - Two of the three reuse existing semantic tokens; transformative gets its own - alias so component code does not reach for --label-purple directly. */ + /* Fleet Action card transformative chip (reuses the purple label hue) */ --action-transformative: var(--label-purple); - /* Chart grid & axis */ - --chart-grid: oklch(0 0 0 / 0.06); - --chart-tick: oklch(0 0 0 / 0.45); + /* ─────────────── Dim defaults (the default theme) ─────────────── */ + /* Raw lightness vars */ + --bg: 0.160; --card-l: 0.212; --band-l: 0.190; --well-l: 0.128; + --bb: 0.320; --bt: 0.400; --bh: 0.460; --hl: 0.300; --border-dir: 1; + --val: 0.970; --title: 0.800; --sub: 0.605; --icon: 0.480; --ink-dir: 1; + --accent-l: 0.745; --brand-fg: oklch(0.15 0 0); + --glow-l: 0.55; --glow-c: 0.085; - /* Typography */ - --font-sans: 'Geist', sans-serif; - --font-serif: 'Instrument Serif', Georgia, serif; - --font-display: 'Instrument Serif', Georgia, serif; - --font-mono: 'Geist Mono', monospace; - - /* Shape */ - --radius: 0.5rem; - - /* Shadows: subtle, glass depth comes from blur not shadow */ - --shadow-x: 0px; - --shadow-y: 1px; - --shadow-blur: 2px; - --shadow-spread: 0px; - --shadow-opacity: 0.10; - --shadow-color: hsl(0 0% 0%); - --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05); - --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05); - --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.08); - --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.08), 0px 1px 2px -1px hsl(0 0% 0% / 0.08); - --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 2px 4px -1px hsl(0 0% 0% / 0.10); - --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 4px 6px -1px hsl(0 0% 0% / 0.10); - --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 8px 10px -1px hsl(0 0% 0% / 0.10); - --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.25); - - /* Material simulation: inherent depth */ - --card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.04), 0 1px 2px 0 oklch(0 0 0 / 0.05); - --button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.04); - --chrome-top-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.45); - - /* Motion timing */ - --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); - --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); - --duration-fast: 150ms; - --duration-base: 220ms; - --duration-slow: 350ms; - - --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); - --terminal-cursor: oklch(1 0 0); - --terminal-cursor-accent: oklch(0 0 0); - --terminal-selection: oklch(1 0 0 / 0.3); -} - -/* ───────────────────────────────────────────────────────────── - DARK THEME -───────────────────────────────────────────────────────────── */ -.dark { - --background: oklch(0.08 0 0); + /* Dark non-derivable tokens (shared by Dim & OLED) */ --foreground: oklch(0.94 0 0); - --card: oklch(0.12 0 0); --card-foreground: oklch(0.94 0 0); --popover: oklch(0.14 0 0 / 0.82); --popover-foreground: oklch(0.94 0 0); --primary: oklch(0.98 0 0); --primary-foreground: oklch(0.10 0 0); - --secondary: oklch(0.12 0 0); + --secondary: oklch(var(--card-l) 0 0); --secondary-foreground: oklch(0.94 0 0); - --muted: oklch(0.12 0 0); + --muted: oklch(var(--card-l) 0 0); --muted-foreground: oklch(0.45 0 0); --accent: oklch(1 0 0 / 0.06); --accent-foreground: oklch(0.94 0 0); --destructive: oklch(0.72 0.18 20); --destructive-foreground: oklch(0.94 0 0); - --border: oklch(1 0 0 / 0.06); - --input: oklch(0.12 0 0); - --ring: oklch(0.78 0.11 195); - - /* Brand: desaturated teal accent */ - --brand: oklch(0.78 0.11 195); - --brand-foreground: oklch(0.05 0 0); - --brand-muted: oklch(0.78 0.11 195 / 0.12); + --destructive-muted: oklch(0.72 0.18 20 / 0.15); + --input: oklch(var(--card-l) 0 0); /* Glass: solid surface system (no blur on static surfaces) */ - --glass: oklch(0.12 0 0); - --glass-border: oklch(1 0 0 / 0.06); + --glass-border: var(--card-border); --glass-highlight: oklch(1 0 0 / 0.06); - /* Semantic status colors */ + /* Semantic status colors (dark set) */ --success: oklch(0.78 0.16 155); --success-foreground: oklch(0.10 0 0); --success-muted: oklch(0.78 0.16 155 / 0.15); @@ -237,41 +125,16 @@ --update: oklch(0.72 0.20 320); --update-foreground: oklch(0.10 0 0); --update-muted: oklch(0.72 0.20 320 / 0.15); - --destructive-muted: oklch(0.72 0.18 20 / 0.15); - /* Charts: cyan leads as the primary data color, amber for peaks, rose for errors */ - --chart-1: var(--brand); - --chart-2: var(--warning); - --chart-3: var(--destructive); - --chart-4: oklch(0.55 0 0); - --chart-5: oklch(0.80 0 0); - - /* Sparkline / data-line styling (centralized) */ - --data-line-width: 1.25px; - --data-peak: var(--warning); - - /* Sidebar */ - --sidebar: oklch(0.08 0 0 / 0.80); + /* Sidebar (tracks the page tone; borders use the shared hairline) */ + --sidebar: oklch(var(--bg) 0 0 / 0.80); --sidebar-foreground: oklch(0.94 0 0); --sidebar-primary: oklch(0.98 0 0); --sidebar-primary-foreground: oklch(0.10 0 0); --sidebar-accent: oklch(1 0 0 / 0.07); --sidebar-accent-foreground: oklch(0.94 0 0); - --sidebar-border: oklch(1 0 0 / 0.06); - --sidebar-ring: oklch(0.78 0.11 195); - /* Stat hierarchy: text brightness tiers */ - --stat-value: oklch(0.93 0 0); - --stat-title: oklch(0.55 0 0); - --stat-subtitle: oklch(0.45 0 0); - --stat-icon: oklch(0.35 0 0); - - /* Card borders: directional lighting */ - --card-border: oklch(1 0 0 / 0.07); - --card-border-top: oklch(1 0 0 / 0.14); - --card-border-hover: oklch(1 0 0 / 0.14); - - /* Label palette: ten raw hues preserved for existing assignments */ + /* Label palette: ten raw hues preserved for existing assignments (dark) */ --label-teal: oklch(0.75 0.10 192); --label-teal-bg: oklch(0.75 0.10 192 / 0.15); --label-blue: oklch(0.72 0.12 250); @@ -305,28 +168,18 @@ --label-custom: var(--label-amber); --label-custom-bg: var(--label-amber-bg); - /* Fleet Action card action-class chip / blast-dot colors (dark theme). */ - --action-transformative: var(--label-purple); - - /* Chart grid & axis */ + /* Chart neutrals + grid/axis (dark) */ + --chart-4: oklch(0.55 0 0); + --chart-5: oklch(0.80 0 0); --chart-grid: oklch(1 0 0 / 0.05); --chart-tick: oklch(1 0 0 / 0.40); - /* Typography */ - --font-sans: 'Geist', sans-serif; - --font-serif: 'Instrument Serif', Georgia, serif; - --font-display: 'Instrument Serif', Georgia, serif; - --font-mono: 'Geist Mono', monospace; - - /* Shape */ - --radius: 0.5rem; - - /* Material simulation: inherent depth */ + /* Material simulation: inherent depth (dark) */ --card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.05); --button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.06); --chrome-top-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.08); - /* Shadows: near-zero, depth comes from surface tone */ + /* Shadows: near-zero, depth comes from surface tone (dark) */ --shadow-x: 0px; --shadow-y: 1px; --shadow-blur: 2px; @@ -342,9 +195,179 @@ --shadow-xl: 0px 8px 10px -1px hsl(0 0% 0% / 0.20); --shadow-2xl: 0px 12px 20px -2px hsl(0 0% 0% / 0.30); - /* Terminal (recessed well, matches page background) - --terminal-fg/cursor/selection: inherited from :root (both themes use a dark terminal) */ - --terminal-bg: oklch(0.08 0 0); + /* Terminal (recessed well, tracks the theme's well tone) */ + --terminal-bg: oklch(var(--well-l) 0 0); + --terminal-fg: oklch(0.85 0 0); + --terminal-cursor: oklch(1 0 0); + --terminal-cursor-accent: oklch(0 0 0); + --terminal-selection: oklch(1 0 0 / 0.3); + + /* ─────────────── Shared (theme-independent) ─────────────── */ + /* Typography. Display is the locked signature face; the interface (sans) and + data (mono) faces are user-swappable via --ui-font / --mono-font, and + --type-scale multiplies the root font-size (see the html rule below). */ + --type-scale: 1; + --ui-font: 'Geist'; + --mono-font: 'Geist Mono'; + --font-sans: var(--ui-font), system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: var(--mono-font), ui-monospace, monospace; + --font-serif: 'Instrument Serif', Georgia, serif; + --font-display: 'Instrument Serif', Georgia, serif; + + /* Shape */ + --radius: 0.5rem; + + /* Motion timing */ + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + --duration-fast: 150ms; + --duration-base: 220ms; + --duration-slow: 350ms; + + --tracking-normal: 0em; + --spacing: 0.25rem; + + /* Density: comfortable defaults. Overridden under .density-compact. */ + --density-scale: 1; + --density-row-y: 0.75rem; + --density-row-x: 1.25rem; + --density-cell-y: 0.5rem; + --density-tile-y: 1rem; + --density-gap: 1rem; +} + +/* ───────────────────────────────────────────────────────────── + ACCENTS (hue + chroma only; lightness comes from the theme) +───────────────────────────────────────────────────────────── */ +/* A tighter, well-spaced wheel: every pair is >=40deg apart and pushed + chromatic so the hues stay distinct at the accent lightness. Kept clear of + success(155) / warning(75) / destructive(20). Steel is the near-neutral. */ +[data-accent="cyan"] { --accent-h: 196; --accent-c: 0.130; } +[data-accent="blue"] { --accent-h: 252; --accent-c: 0.165; } +[data-accent="violet"] { --accent-h: 298; --accent-c: 0.190; } +[data-accent="magenta"] { --accent-h: 344; --accent-c: 0.180; } +[data-accent="orange"] { --accent-h: 42; --accent-c: 0.165; } +[data-accent="amber"] { --accent-h: 88; --accent-c: 0.165; } +[data-accent="lime"] { --accent-h: 132; --accent-c: 0.195; } +[data-accent="steel"] { --accent-h: 250; --accent-c: 0.040; } + +/* ───────────────────────────────────────────────────────────── + OLED: true black for power-saving panels. Only the raw surface, + ink and glow vars change; the dark non-derivable tokens inherit + from :root, and the opaque-grey borders keep cards from merging + into the black the way 6%-white hairlines did. +───────────────────────────────────────────────────────────── */ +[data-theme="oled"] { + --bg: 0.035; --card-l: 0.105; --band-l: 0.072; --well-l: 0.020; + --bb: 0.262; --bt: 0.345; --bh: 0.405; --hl: 0.235; --border-dir: 1; + --val: 0.950; --title: 0.780; --sub: 0.575; --icon: 0.445; --ink-dir: 1; + --accent-l: 0.745; --brand-fg: oklch(0.11 0 0); + --glow-l: 0.46; --glow-c: 0.085; +} + +/* ───────────────────────────────────────────────────────────── + LIGHT: the existing light theme. Borders are opaque and + directionally lit (the lit top edge is lighter, sides darker, + so --border-dir flips to -1). +───────────────────────────────────────────────────────────── */ +[data-theme="light"] { + color-scheme: light; + + /* Raw lightness vars */ + --bg: 0.965; --card-l: 1.000; --band-l: 0.972; --well-l: 0.945; + --bb: 0.855; --bt: 0.910; --bh: 0.780; --hl: 0.885; --border-dir: -1; + --val: 0.190; --title: 0.400; --sub: 0.500; --icon: 0.620; --ink-dir: -1; + --accent-l: 0.525; --brand-fg: oklch(0.99 0 0); + --glow-l: 0.90; --glow-c: 0.040; + + /* Light non-derivable tokens */ + --foreground: oklch(0.15 0 0); + --card-foreground: oklch(0.15 0 0); + --popover: oklch(1 0 0 / 0.92); + --popover-foreground: oklch(0.15 0 0); + --primary: oklch(0.15 0 0); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0 0 0 / 0.05); + --secondary-foreground: oklch(0.15 0 0); + --muted: oklch(0 0 0 / 0.04); + --muted-foreground: oklch(0.50 0 0); + --accent: oklch(0 0 0 / 0.06); + --accent-foreground: oklch(0.15 0 0); + --destructive: oklch(0.63 0.18 20); + --destructive-foreground: oklch(1 0 0); + --destructive-muted: oklch(0.63 0.18 20 / 0.12); + --input: oklch(0.97 0 0); + + --glass-border: var(--card-border); + --glass-highlight: oklch(0 0 0 / 0.06); + + --success: oklch(0.65 0.16 155); + --success-foreground: oklch(1 0 0); + --success-muted: oklch(0.65 0.16 155 / 0.12); + --warning: oklch(0.75 0.14 75); + --warning-foreground: oklch(0.20 0 0); + --warning-muted: oklch(0.75 0.14 75 / 0.12); + --info: oklch(0.62 0.14 250); + --info-foreground: oklch(1 0 0); + --info-muted: oklch(0.62 0.14 250 / 0.12); + --update: oklch(0.62 0.22 320); + --update-foreground: oklch(1 0 0); + --update-muted: oklch(0.62 0.22 320 / 0.12); + + --sidebar: oklch(0.98 0 0 / 0.80); + --sidebar-foreground: oklch(0.15 0 0); + --sidebar-primary: oklch(0.15 0 0); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0 0 0 / 0.06); + --sidebar-accent-foreground: oklch(0.15 0 0); + + /* Label palette (light) */ + --label-teal: oklch(0.55 0.12 192); + --label-teal-bg: oklch(0.55 0.12 192 / 0.12); + --label-blue: oklch(0.55 0.14 250); + --label-blue-bg: oklch(0.55 0.14 250 / 0.12); + --label-purple: oklch(0.55 0.14 300); + --label-purple-bg: oklch(0.55 0.14 300 / 0.12); + --label-rose: oklch(0.55 0.16 10); + --label-rose-bg: oklch(0.55 0.16 10 / 0.12); + --label-amber: oklch(0.60 0.16 60); + --label-amber-bg: oklch(0.60 0.16 60 / 0.12); + --label-green: oklch(0.55 0.15 150); + --label-green-bg: oklch(0.55 0.15 150 / 0.12); + --label-orange: oklch(0.58 0.16 45); + --label-orange-bg: oklch(0.58 0.16 45 / 0.12); + --label-pink: oklch(0.58 0.16 340); + --label-pink-bg: oklch(0.58 0.16 340 / 0.12); + --label-cyan: oklch(0.58 0.12 210); + --label-cyan-bg: oklch(0.58 0.12 210 / 0.12); + --label-slate: oklch(0.50 0.01 250); + --label-slate-bg: oklch(0.50 0.01 250 / 0.12); + + /* Chart neutrals + grid/axis (light) */ + --chart-4: oklch(0.70 0 0); + --chart-5: oklch(0.45 0 0); + --chart-grid: oklch(0 0 0 / 0.06); + --chart-tick: oklch(0 0 0 / 0.45); + + /* Material simulation (light) */ + --card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.04), 0 1px 2px 0 oklch(0 0 0 / 0.05); + --button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.04); + --chrome-top-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.45); + + /* Shadows (light) */ + --shadow-opacity: 0.10; + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.08); + --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.08), 0px 1px 2px -1px hsl(0 0% 0% / 0.08); + --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.25); + + /* Terminal stays a dark well even in the light theme */ + --terminal-bg: oklch(0.12 0 0); } /* ───────────────────────────────────────────────────────────── @@ -400,6 +423,10 @@ --color-card-border-top: var(--card-border-top); --color-card-border-hover: var(--card-border-hover); + --color-band: var(--band); + --color-well: var(--well); + --color-hairline: var(--hairline); + --color-chart-grid: var(--chart-grid); --color-chart-tick: var(--chart-tick); @@ -445,26 +472,41 @@ BASE STYLES ───────────────────────────────────────────────────────────── */ @layer base { - :root { - color-scheme: dark; - } - * { @apply border-border outline-ring/50; } body { @apply text-foreground; + /* Ambient top-center glow tracks the accent hue and the --glow knob. Shows + on screens whose root is transparent (e.g. Login). Full-screen app shells + that paint an opaque --background must use .app-canvas instead, or they + occlude this. */ background: radial-gradient( - circle at 50% 0%, - oklch(0.95 0.02 60 / 0.3), - transparent + ellipse 156% 106% at 50% -6%, + var(--glow-color), + transparent 70% ), var(--background); + background-attachment: fixed; } } +/* The page canvas: opaque --background base plus the ambient accent glow. Use on + full-screen app shells (EditorLayout, the loading wrapper) that would otherwise + paint a flat bg-background over the body glow and hide it entirely. */ +.app-canvas { + background: + radial-gradient( + ellipse 156% 106% at 50% -6%, + var(--glow-color), + transparent 70% + ), + var(--background); + background-attachment: fixed; +} + /* Density: compact mode compresses row/cell padding by ~50%. */ body.density-compact { --density-scale: 0.75; @@ -475,17 +517,6 @@ body.density-compact { --density-gap: 0.75rem; } -/* Dark mode: teal-tinted ambient glow */ -.dark body { - background: - radial-gradient( - circle at 50% 0%, - oklch(0.25 0.05 250 / 0.15), - transparent - ), - var(--background); -} - /* ───────────────────────────────────────────────────────────── TYPOGRAPHY & RENDERING ───────────────────────────────────────────────────────────── */ @@ -497,6 +528,12 @@ body, overflow: hidden; } +/* Text-size control: one root multiplier scales the whole rem-based type ramp. + Layout px is untouched; only rem-sized text grows/shrinks. */ +html { + font-size: calc(16px * var(--type-scale)); +} + body { font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 21bb4e6d..ed20ae00 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,8 +4,10 @@ import './index.css' import App from './App.tsx' import ErrorBoundary from './components/ErrorBoundary.tsx' import { initializeDensity } from './hooks/use-density' +import { initializeTheme } from './hooks/use-theme' initializeDensity() +initializeTheme() createRoot(document.getElementById('root')!).render(