mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-04 16:07:55 +00:00
feat(appearance): add theme, accent, contrast, and typography personalization (#1307)
* feat(appearance): add theme, accent, contrast, and typography personalization Expand Settings to Appearance into a full personalization surface and add a quick switcher to the top bar (the palette button between search and notifications). Choices are saved to the browser, sync across tabs, and apply before first paint so there is no flash on reload. - Themes: Dim (the default raised charcoal), OLED true black, Light, and Auto (follows the OS and re-resolves live when it flips). - Accent: an eight-hue wheel (cyan default) that drives the one data color across charts, rails, focus rings, active states, and the ambient glow. - Fine-tune sliders: a master Contrast that spreads page, ink, and borders together, plus Border brightness and Ambient glow. - Typography: swappable interface (Geist / IBM Plex Sans / Hanken Grotesk) and data (Geist Mono / IBM Plex Mono / Fira Code) faces, and a text-size control (continuous slider in Settings, S/M/L/XL presets in the popover, kept in sync). The display serif stays locked as the signature face. Surfaces, borders, and ink derive from per-theme lightness values so the live knobs scale the whole UI through CSS, and the opaque directional borders read on every panel including true black. A live preview reflects changes in real time. Adds a documentation page under Features. * fix(appearance): keep contrast-driven tokens in gamut and add picker keyboard nav Clamp the contrast and knob driven surface, border, and ink lightness so the full slider range stays a valid color. The page now always sits below the card tone, so page/card separation no longer collapses at high contrast in Light; the lit border edge never reaches white in Light at the low end of the knobs; and the OLED and Dim extremes resolve to valid black/white instead of out-of-range values. Add the radiogroup keyboard model (roving tabindex plus Arrow / Home / End) to the accent and type pickers through a shared hook, matching the segmented control. One item is tabbable and arrow keys move focus and selection together.
This commit is contained in:
@@ -94,6 +94,7 @@
|
||||
"group": "Features",
|
||||
"pages": [
|
||||
"features/overview",
|
||||
"features/appearance",
|
||||
{
|
||||
"group": "Stacks",
|
||||
"expanded": true,
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## 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.
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en" class="dark" data-theme="dim" data-accent="cyan">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -10,7 +10,7 @@
|
||||
<title>Sencho</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&family=Instrument+Serif:ital,wght@0,400;1,400&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&family=Instrument+Serif:ital,wght@0,400;1,400&family=IBM+Plex+Sans:wght@400;500;600&family=Hanken+Grotesk:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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 <html> 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 <head>).
|
||||
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 */ }
|
||||
})();
|
||||
|
||||
@@ -14,7 +14,7 @@ function AppContent() {
|
||||
|
||||
if (appStatus === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="min-h-screen flex items-center justify-center app-canvas">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<GlobalCommandPaletteProvider>
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
||||
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
|
||||
<GlobalCommandPalette
|
||||
navItems={navItems}
|
||||
onNavigate={handleNavigate}
|
||||
@@ -417,6 +418,7 @@ export default function EditorLayout() {
|
||||
mobileNavOpen={mobileNavOpen}
|
||||
onMobileNavOpenChange={setMobileNavOpen}
|
||||
search={<GlobalCommandPaletteTrigger />}
|
||||
themeSwitch={<ThemeQuickSwitch />}
|
||||
notifications={
|
||||
<NotificationPanel
|
||||
notifications={notifications}
|
||||
@@ -429,8 +431,6 @@ export default function EditorLayout() {
|
||||
}
|
||||
userMenu={
|
||||
<UserProfileDropdown
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
onOpenSettings={() => handleOpenSettings('account')}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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<typeof mockMatchMedia>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Theme>(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;
|
||||
}
|
||||
@@ -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 */}
|
||||
<div className="flex flex-1 min-w-0 items-center justify-end gap-2">
|
||||
{search}
|
||||
{themeSwitch}
|
||||
{notifications}
|
||||
{userMenu}
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<div className="flex items-center justify-between gap-3 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Appearance
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
options={THEME_OPTIONS}
|
||||
onChange={setTheme}
|
||||
iconOnly
|
||||
ariaLabel="Theme"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t border-card-border/60">
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
||||
import { AccentPicker } from '@/components/theme/AccentPicker';
|
||||
import { ThemePreview } from '@/components/theme/ThemePreview';
|
||||
import { TypeChips } from '@/components/theme/TypeChips';
|
||||
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from '@/components/theme/typeOptions';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
|
||||
|
||||
const DENSITY_OPTIONS: { value: Density; label: string }[] = [
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
@@ -17,13 +25,152 @@ const DENSITY_DESCRIPTIONS: Record<Density, string> = {
|
||||
compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
|
||||
};
|
||||
|
||||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
export function AppearanceSection() {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
||||
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale,
|
||||
} = useTheme();
|
||||
const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SettingsSection title="Theme" kicker="this browser">
|
||||
<div className="pt-3">
|
||||
<ThemePreview />
|
||||
</div>
|
||||
|
||||
<SettingsField
|
||||
label="Mode"
|
||||
helper="Dim lifts surfaces off black; OLED is true black; Light inverts; Auto follows your OS."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
options={THEME_MODE_OPTIONS}
|
||||
onChange={setTheme}
|
||||
ariaLabel="Theme mode"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Accent"
|
||||
helper={`The one data color, used for charts, rails, and focus. Currently ${accentLabel}.`}
|
||||
align="start"
|
||||
>
|
||||
<AccentPicker value={accent} onChange={setAccent} />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Contrast"
|
||||
helper="Master contrast: spreads the page, ink, and borders together. Pair high contrast with OLED for the crispest panel."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[contrast]}
|
||||
min={CONTRAST.min}
|
||||
max={CONTRAST.max}
|
||||
step={CONTRAST.step}
|
||||
onValueChange={([v]) => setContrast(v)}
|
||||
aria-label="Contrast"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(contrast)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Border brightness"
|
||||
helper="Lift or soften every hairline so separation reads exactly how you like it."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[borderBoost]}
|
||||
min={BORDER_BOOST.min}
|
||||
max={BORDER_BOOST.max}
|
||||
step={BORDER_BOOST.step}
|
||||
onValueChange={([v]) => setBorderBoost(v)}
|
||||
aria-label="Border brightness"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{fmtSigned(borderBoost)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Ambient glow"
|
||||
helper="Intensity of the accent-tinted glow behind the page."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[glow]}
|
||||
min={GLOW.min}
|
||||
max={GLOW.max}
|
||||
step={GLOW.step}
|
||||
onValueChange={([v]) => setGlow(v)}
|
||||
aria-label="Ambient glow"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{glow.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsActions>
|
||||
<SettingsSecondaryButton
|
||||
onClick={() => {
|
||||
setContrast(CONTRAST.default);
|
||||
setBorderBoost(BORDER_BOOST.default);
|
||||
setGlow(GLOW.default);
|
||||
}}
|
||||
>
|
||||
Reset fine-tune
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsActions>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Typography" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Interface font"
|
||||
helper="The sans face for body, labels, nav, and buttons. Display headings stay Instrument Serif."
|
||||
align="start"
|
||||
>
|
||||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Data font"
|
||||
helper="The mono face for terminal, stats, codes, and timestamps."
|
||||
align="start"
|
||||
>
|
||||
<TypeChips value={monoFont} options={MONO_FONT_OPTIONS} onChange={setMonoFont} ariaLabel="Data font" />
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Text size"
|
||||
helper="Scales the whole interface from a single root multiplier. Default 1.00×."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[typeScale]}
|
||||
min={TYPE_SCALE.min}
|
||||
max={TYPE_SCALE.max}
|
||||
step={TYPE_SCALE.step}
|
||||
onValueChange={([v]) => setTypeScale(v)}
|
||||
aria-label="Text size"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{typeScale.toFixed(2)}×
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Display" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Density"
|
||||
|
||||
@@ -46,8 +46,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
id: 'appearance',
|
||||
group: 'identity',
|
||||
label: 'Appearance',
|
||||
description: 'Density and display preferences saved to this browser.',
|
||||
keywords: ['density', 'comfortable', 'compact', 'spacing', 'display', 'theme'],
|
||||
description: 'Theme, accent, density, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { AccentPicker } from './AccentPicker';
|
||||
|
||||
// Covers the roving-radio keyboard model added via useRovingRadio (shared with
|
||||
// TypeChips). ACCENTS order is orange, amber, lime, cyan, blue, violet, magenta, steel.
|
||||
|
||||
describe('AccentPicker keyboard model', () => {
|
||||
it('exposes exactly one tabbable radio (the selected one)', () => {
|
||||
render(<AccentPicker value="cyan" onChange={() => {}} />);
|
||||
const radios = screen.getAllByRole('radio');
|
||||
const tabbable = radios.filter((r) => r.getAttribute('tabindex') === '0');
|
||||
expect(tabbable).toHaveLength(1);
|
||||
expect(tabbable[0].getAttribute('aria-label')).toBe('Cyan');
|
||||
});
|
||||
|
||||
it('ArrowRight moves selection to the next accent, ArrowLeft to the previous', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AccentPicker value="cyan" onChange={onChange} />);
|
||||
const cyan = screen.getByRole('radio', { name: 'Cyan' });
|
||||
fireEvent.keyDown(cyan, { key: 'ArrowRight' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('blue');
|
||||
fireEvent.keyDown(cyan, { key: 'ArrowLeft' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('lime');
|
||||
});
|
||||
|
||||
it('Home selects the first accent, End selects the last', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AccentPicker value="cyan" onChange={onChange} />);
|
||||
const cyan = screen.getByRole('radio', { name: 'Cyan' });
|
||||
fireEvent.keyDown(cyan, { key: 'Home' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('orange');
|
||||
fireEvent.keyDown(cyan, { key: 'End' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('steel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ACCENTS, type AccentId } from '@/hooks/use-theme';
|
||||
import { useRovingRadio } from './useRovingRadio';
|
||||
|
||||
interface AccentPickerProps {
|
||||
value: AccentId;
|
||||
onChange: (accent: AccentId) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ACCENT_IDS = ACCENTS.map((a) => a.id);
|
||||
|
||||
/**
|
||||
* The eight swappable accent hues as a swatch grid. Each dot renders the accent
|
||||
* at a representative lightness so it reads on both dark and light panels; the
|
||||
* selected swatch gets a ring in its own hue. Shared by the topbar quick switch
|
||||
* and Settings → Appearance.
|
||||
*/
|
||||
export function AccentPicker({ value, onChange, className }: AccentPickerProps) {
|
||||
const itemProps = useRovingRadio(ACCENT_IDS, value, onChange);
|
||||
return (
|
||||
<div role="radiogroup" aria-label="Accent" className={cn('grid grid-cols-4 gap-2', className)}>
|
||||
{ACCENTS.map((a, i) => {
|
||||
const color = `oklch(0.745 ${a.c} ${a.h})`;
|
||||
const active = a.id === value;
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={a.label}
|
||||
title={a.label}
|
||||
onClick={() => onChange(a.id)}
|
||||
{...itemProps(i)}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
active ? 'border-card-border-hover bg-card' : 'border-card-border hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{
|
||||
background: color,
|
||||
boxShadow: active ? `0 0 0 2px var(--card), 0 0 0 4px ${color}` : undefined,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors hover:border-t-card-border-hover">
|
||||
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
|
||||
<div className="flex flex-col gap-4 p-5 pl-6">
|
||||
{/* Mini masthead: rail + display state word + mono kicker + status dots */}
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-brand">
|
||||
fleet · preview
|
||||
</div>
|
||||
<div className="font-display text-2xl italic leading-none text-stat-value">
|
||||
Healthy
|
||||
</div>
|
||||
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
6 nodes · last sync 9s
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-success" />
|
||||
<span className="h-2 w-2 rounded-full bg-warning" />
|
||||
<span className="h-2 w-2 rounded-full bg-destructive" />
|
||||
<span className="h-2 w-2 rounded-full bg-brand" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ink tiers */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm">
|
||||
<span className="text-stat-value">Value</span>
|
||||
<span className="text-stat-title">Title</span>
|
||||
<span className="text-stat-subtitle">Subtitle</span>
|
||||
<span className="text-stat-icon">Icon</span>
|
||||
</div>
|
||||
|
||||
{/* Surface ladder */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{LADDER.map(({ bg, label }) => (
|
||||
<div key={label} className="flex flex-col items-center gap-1.5">
|
||||
<div className={cn('h-9 w-full rounded-md border border-card-border', bg)} />
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Brand progress + key affordances */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-well">
|
||||
<div className="h-full w-[62%] rounded-full bg-brand" />
|
||||
</div>
|
||||
<span className="pointer-events-none select-none rounded-md border border-card-border bg-card px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle shadow-btn-glow">
|
||||
Outline
|
||||
</span>
|
||||
<span className="pointer-events-none select-none rounded-md bg-brand px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-brand-foreground">
|
||||
Primary
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg"
|
||||
title="Theme"
|
||||
aria-label="Theme"
|
||||
>
|
||||
<Palette className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 overflow-hidden rounded-md p-0" align="end" sideOffset={8}>
|
||||
{/* Masthead */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||
<span className="font-display text-xl italic leading-none text-stat-value">
|
||||
Theme
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{themeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="flex flex-col gap-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Mode
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
options={THEME_MODE_OPTIONS}
|
||||
onChange={setTheme}
|
||||
ariaLabel="Theme mode"
|
||||
iconOnly
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Accent */}
|
||||
<div className="space-y-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Accent
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||||
{accentLabel}
|
||||
</span>
|
||||
</div>
|
||||
<AccentPicker value={accent} onChange={setAccent} />
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div className="space-y-3 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Type
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Interface
|
||||
</span>
|
||||
<TypeChips value={uiFont} options={UI_FONT_OPTIONS} onChange={setUiFont} ariaLabel="Interface font" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Data
|
||||
</span>
|
||||
<TypeChips value={monoFont} options={MONO_FONT_OPTIONS} onChange={setMonoFont} ariaLabel="Data font" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block font-mono text-[9px] uppercase tracking-[0.16em] text-stat-icon">
|
||||
Size
|
||||
</span>
|
||||
<TypeChips
|
||||
value={sizeIdForScale(typeScale)}
|
||||
options={SIZE_OPTIONS}
|
||||
onChange={onSize}
|
||||
columns={4}
|
||||
ariaLabel="Text size"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
||||
<p className="font-mono text-[10px] leading-4 uppercase tracking-[0.14em] text-stat-subtitle/70">
|
||||
Saved to this browser · fine-tune borders & glow in Settings
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TypeChipOption } from './typeOptions';
|
||||
import { useRovingRadio } from './useRovingRadio';
|
||||
|
||||
interface TypeChipsProps<T extends string> {
|
||||
value: T;
|
||||
options: TypeChipOption<T>[];
|
||||
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<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
columns = 3,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: TypeChipsProps<T>) {
|
||||
const itemProps = useRovingRadio(options.map((o) => o.id), value, onChange);
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('grid gap-2', columns === 4 ? 'grid-cols-4' : 'grid-cols-3', className)}
|
||||
>
|
||||
{options.map((o, i) => {
|
||||
const active = o.id === value;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
title={o.name}
|
||||
onClick={() => onChange(o.id)}
|
||||
{...itemProps(i)}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-1.5 rounded-md border px-2 py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
active
|
||||
? 'border-brand/40 bg-brand/10'
|
||||
: 'border-card-border bg-card hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<span className="leading-none text-stat-value" style={o.previewStyle}>
|
||||
{o.preview}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[9px] uppercase tracking-[0.12em]',
|
||||
active ? 'text-brand' : 'text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{o.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends string> {
|
||||
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<UiFont>[] = 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<MonoFont>[] = MONO_FONTS.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
preview: '01',
|
||||
previewStyle: { fontFamily: `'${f.id}', monospace`, fontSize: '16px' },
|
||||
}));
|
||||
|
||||
export const SIZE_OPTIONS: TypeChipOption<string>[] = 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 ?? '';
|
||||
}
|
||||
@@ -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<T extends string>(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<HTMLButtonElement>, 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<HTMLButtonElement>) => onKeyDown(e, index),
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface SegmentedControlProps<T extends string> {
|
||||
onChange: (next: T) => void;
|
||||
ariaLabel?: string;
|
||||
iconOnly?: boolean;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ export function SegmentedControl<T extends string>({
|
||||
onChange,
|
||||
ariaLabel,
|
||||
iconOnly,
|
||||
fullWidth,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
@@ -61,6 +63,7 @@ export function SegmentedControl<T extends string>({
|
||||
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<T extends string>({
|
||||
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',
|
||||
|
||||
@@ -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<HTMLElement> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
}
|
||||
|
||||
describe('useTheme', () => {
|
||||
beforeEach(() => {
|
||||
// Sync <html> 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 <html> 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 <html> 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');
|
||||
});
|
||||
});
|
||||
@@ -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-<html> 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<string>(THEME_MODES.map((m) => m.id));
|
||||
const ACCENT_IDS = new Set<string>(ACCENTS.map((a) => a.id));
|
||||
const UI_FONT_IDS = new Set<string>(UI_FONTS.map((f) => f.id));
|
||||
const MONO_FONT_IDS = new Set<string>(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<ThemeState> | 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<ThemeState>) {
|
||||
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 <html> 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;
|
||||
}
|
||||
+286
-249
@@ -5,225 +5,113 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
LIGHT THEME
|
||||
THEME ENGINE
|
||||
Theme is an attribute on <html> (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 <html> 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;
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user