diff --git a/03_implementation/ui/src/app/store.ts b/03_implementation/ui/src/app/store.ts index 8e8bc3d4..f5fb5478 100644 --- a/03_implementation/ui/src/app/store.ts +++ b/03_implementation/ui/src/app/store.ts @@ -87,6 +87,59 @@ export function tabIdFromHash(hash: string): string | null { return TAB_IDS.includes(tabId as (typeof TAB_IDS)[number]) ? tabId : null; } +/** + * Settings subtab URL-hash routing (W15-A17). + * + * Tabs with nested subtabs encode the selection in the trailing path + * segment, e.g. `#settings/general`, `#settings/mcp`. The first segment + * is owned by `tabIdFromHash`; the second segment is owned by the tab + * itself. This keeps deep links stable across reloads without coupling + * the subtab list to the global hash table. + * + * Note: the helper is intentionally generic over both the tab head and + * the allowed subtab keys so adjacent tabs (Voice / A18) can adopt the + * same routing shape without conflicting on `store.ts`. The Voice agent + * lane is expected to add `voiceSubtabFromHash` next to this helper. + */ +export function subtabFromHash( + hash: string, + expectedHead: string, + allowed: readonly K[], +): K | null { + const raw = hash.replace(/^#/, "").trim(); + if (!raw) { + return null; + } + // Accept both `settings/general` and the legacy `settings.general` form; + // colon is reserved for dashboard mode routing. + const [head, sub] = raw.split(/[/.]/, 2); + if (head !== expectedHead || !sub) { + return null; + } + const decoded = decodeURIComponent(sub.toLowerCase()); + return (allowed as readonly string[]).includes(decoded) ? (decoded as K) : null; +} + +/** + * Convenience wrapper for the Settings tab. Keeps the rest of the app from + * having to import the generic helper and the subtab list separately. + */ +export const SETTINGS_SUBTAB_KEYS = [ + "general", + "providers", + "agents", + "mcp", + "printers", + "environment", + "updates", + "about", +] as const; +export type SettingsSubtabKey = (typeof SETTINGS_SUBTAB_KEYS)[number]; + +export function settingsSubtabFromHash(hash: string): SettingsSubtabKey | null { + return subtabFromHash(hash, "settings", SETTINGS_SUBTAB_KEYS); +} + function initialActiveTabId(): string { if (typeof window === "undefined") { return "dashboard"; diff --git a/03_implementation/ui/src/components/settings/GeneralSubtab.tsx b/03_implementation/ui/src/components/settings/GeneralSubtab.tsx index 631b1f92..b57d7cce 100644 --- a/03_implementation/ui/src/components/settings/GeneralSubtab.tsx +++ b/03_implementation/ui/src/components/settings/GeneralSubtab.tsx @@ -30,9 +30,19 @@ * is internal state). */ import { useEffect, useState } from "react"; -import { Languages, Moon, MonitorCog, Save } from "lucide-react"; +import { Languages, Moon, MonitorCog, Palette, Save } from "lucide-react"; import { adapters } from "../../api/adapters"; import type { AppSettings, ThemeName } from "../../types/settings"; +import { + applyPalette, + DEFAULT_PALETTE_ID, + isNamedPaletteId, + NAMED_PALETTES, + PALETTE_STORAGE_KEY, + readStoredPaletteId, + writeStoredPaletteId, + type NamedPaletteId, +} from "../../theme/palettes"; type DashboardModePref = "simple" | "advanced" | "custom"; type UIModePref = "full" | "simple"; @@ -96,6 +106,12 @@ export function GeneralSubtab() { const [dashboardMode, setDashboardMode] = useState(() => readLocal(LS_DASHBOARD_MODE, ["simple", "advanced", "custom"] as const, "advanced"), ); + // Named palette is persisted to localStorage under the dedicated + // `h3d.theme.palette` key (W15-A17). Backend persistence is owned by + // A20 (`/api/settings/themes`); until that endpoint exists, the + // palette is preview-only and survives reload via local storage. + const [palette, setPalette] = useState(() => readStoredPaletteId()); + const [savedPalette, setSavedPalette] = useState(palette); const [savedLanguage, setSavedLanguage] = useState(language); const [savedUiMode, setSavedUiMode] = useState(uiMode); @@ -125,11 +141,20 @@ export function GeneralSubtab() { }; }, []); + // Live preview: every palette change re-applies the CSS variables. + // We intentionally do this BEFORE save — clicking a swatch should + // preview immediately, like VS Code's theme picker, with the persist + // step happening on Save (so revert == reload, or pick "default"). + useEffect(() => { + applyPalette(palette); + }, [palette]); + const dirty = (serverTheme !== null && theme !== serverTheme) || language !== savedLanguage || uiMode !== savedUiMode || - dashboardMode !== savedDashboardMode; + dashboardMode !== savedDashboardMode || + palette !== savedPalette; const handleSave = async () => { if (!dirty || busy) return; @@ -145,14 +170,20 @@ export function GeneralSubtab() { writeLocal(LS_LANGUAGE, language); writeLocal(LS_UI_MODE, uiMode); writeLocal(LS_DASHBOARD_MODE, dashboardMode); + // The named palette has its own dedicated key (PALETTE_STORAGE_KEY) + // because the W8-3 ThemeProvider already owns `h3d.theme` for + // light/dark mode — keeping these orthogonal is the contract. + writeStoredPaletteId(palette); setSavedLanguage(language); setSavedUiMode(uiMode); setSavedDashboardMode(dashboardMode); + setSavedPalette(palette); await adapters.emitProofEvent("settings.general.saved", { theme, language, uiMode, dashboardMode, + palette, }); setStatus({ tone: "ok", text: "Preferences saved." }); } catch (error) { @@ -211,6 +242,79 @@ export function GeneralSubtab() { +
+ + Theme Palette + +

+ Switches the live CSS variables. Saved to your browser + ({PALETTE_STORAGE_KEY}); preview-only until the + backend /api/settings/themes endpoint ships. +

+
+ {NAMED_PALETTES.map((opt) => { + const selected = palette === opt.id; + return ( + + ); + })} +
+ {palette !== DEFAULT_PALETTE_ID && ( +

+ Pick {NAMED_PALETTES[0].label} to revert to the shipped baseline. +

+ )} +
+
diff --git a/03_implementation/ui/src/components/settings/SettingsPage.tsx b/03_implementation/ui/src/components/settings/SettingsPage.tsx index 477910c5..add3a632 100644 --- a/03_implementation/ui/src/components/settings/SettingsPage.tsx +++ b/03_implementation/ui/src/components/settings/SettingsPage.tsx @@ -1,20 +1,28 @@ /** * Settings landing page backed by the local GUI API. * - * Hosts the four canonical subtabs: + * Hosts the eight canonical subtabs: + * - General · theme, language, defaults * - Providers · LLM endpoint config (read-only `llm_policy.yaml` view) + * - Agents · agent policy preview + * - MCP · active MCP file locks * - Printers · 12-printer fleet from `printers.toml` + optional health * - Environment · env-var presence with values redacted to [set]/[not set] + * - Updates · update center, version + rollback * - About · version, license, links to GitHub + docs * + * Subtab routing (W15-A17): the URL hash is authoritative — visiting + * `#settings/` selects the matching subtab; clicking a subtab + * replaces the hash with `#settings/`. We use `replaceState` (not + * `pushState`) so navigating subtabs doesn't pollute browser history. * The provider chain itself and the service-health endpoint are owned by * other tracks; this page only consumes their public shapes (and gracefully * degrades when they're not yet shipped). * - * Subtab routing is internal `useState` — we MUST NOT introduce - * react-router; the universal shell uses the TABS pattern only. + * Subtab routing follows the same `#/` pattern AppRegistry uses + * (`#apps/`). No react-router; the universal shell uses TABS only. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Bot, Cog, @@ -29,7 +37,12 @@ import { } from "lucide-react"; import { Panel } from "../layout/Panel"; import { ResizablePane } from "../layout/ResizablePane"; -import { useStore } from "../../app/store"; +import { + SETTINGS_SUBTAB_KEYS, + settingsSubtabFromHash, + useStore, + type SettingsSubtabKey, +} from "../../app/store"; import { GeneralSubtab } from "./GeneralSubtab"; import { ProvidersSubtab } from "./ProvidersSubtab"; import { PrintersSubtab } from "./PrintersSubtab"; @@ -39,15 +52,10 @@ import { AgentConfigSection } from "./AgentConfigSection"; import { UpdateCenterSubtab } from "./UpdateCenterSubtab"; import { McpSubtab } from "./McpSubtab"; -type SubtabKey = - | "general" - | "providers" - | "agents" - | "mcp" - | "printers" - | "environment" - | "about" - | "updates"; +type SubtabKey = SettingsSubtabKey; + +const SETTINGS_HASH_PREFIX = "settings"; +const DEFAULT_SUBTAB: SubtabKey = "general"; const SUBTABS: { key: SubtabKey; label: string; Icon: typeof Cpu; description: string }[] = [ { key: "general", label: "General", Icon: MonitorCog, description: "Theme, language, defaults" }, @@ -60,9 +68,82 @@ const SUBTABS: { key: SubtabKey; label: string; Icon: typeof Cpu; description: s { key: "about", label: "About", Icon: Info, description: "Version + links" }, ]; +// Defensive: enforce SUBTABS and SETTINGS_SUBTAB_KEYS stay in sync at +// load time so reorderings here trip a console warning in dev instead of +// silently breaking the URL contract. `import.meta.env.DEV` is the Vite +// compile-time flag; we guard the access so non-Vite consumers (Jest) +// don't choke on the missing import.meta. +const __isDev: boolean = + typeof import.meta !== "undefined" && + (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true; +if (__isDev && SUBTABS.length !== SETTINGS_SUBTAB_KEYS.length) { + // eslint-disable-next-line no-console + console.warn( + `[SettingsPage] SUBTABS (${SUBTABS.length}) and SETTINGS_SUBTAB_KEYS (${SETTINGS_SUBTAB_KEYS.length}) length mismatch`, + ); +} + +function readInitialSubtab(): SubtabKey { + if (typeof window === "undefined") return DEFAULT_SUBTAB; + return settingsSubtabFromHash(window.location.hash) ?? DEFAULT_SUBTAB; +} + export function SettingsPage() { const setActiveTabId = useStore((state) => state.setActiveTabId); - const [active, setActive] = useState("general"); + const [active, setActive] = useState(readInitialSubtab); + + // Sync the subtab selection FROM the URL hash so deep links and the + // back/forward buttons select the correct subtab. The interval is a + // safety-net for environments that swallow hashchange (Playwright on + // some Firefox builds); 500ms matches App.tsx's tab-level cadence. + useEffect(() => { + const sync = () => { + const next = settingsSubtabFromHash(window.location.hash); + if (next && next !== active) setActive(next); + }; + sync(); + window.addEventListener("hashchange", sync); + window.addEventListener("popstate", sync); + const timer = window.setInterval(sync, 500); + return () => { + window.removeEventListener("hashchange", sync); + window.removeEventListener("popstate", sync); + window.clearInterval(timer); + }; + }, [active]); + + // Sync the URL hash TO the subtab selection. We use replaceState so + // navigating subtabs doesn't bloat browser history — mirrors the + // tab-level logic in App.tsx. + useEffect(() => { + if (typeof window === "undefined") return; + const desired = `#${SETTINGS_HASH_PREFIX}/${active}`; + if (window.location.hash === desired) return; + // Only rewrite when the head segment is "settings" — never clobber + // a hash that points at a different tab (the user may have just + // clicked the sidebar). + const head = window.location.hash.replace(/^#/, "").split(/[/:.]/, 1)[0]; + if (head && head !== SETTINGS_HASH_PREFIX) return; + window.history.replaceState(null, "", desired); + }, [active]); + + const handleSelect = (key: SubtabKey) => { + setActive(key); + if (typeof window !== "undefined") { + const next = `#${SETTINGS_HASH_PREFIX}/${key}`; + if (window.location.hash !== next) { + window.history.replaceState(null, "", next); + // Fire hashchange so AgentChatMirror and any other listeners + // see the navigation; replaceState alone does not emit it. + try { + window.dispatchEvent(new HashChangeEvent("hashchange")); + } catch { + /* JSDOM occasionally fails to construct HashChangeEvent */ + } + } + } + }; + const meta = SUBTABS.find((s) => s.key === active) ?? SUBTABS[0]; return ( @@ -105,7 +186,7 @@ export function SettingsPage() { aria-controls={`settings-panel-${s.key}`} id={`settings-tab-${s.key}`} data-testid={`settings-subtab-${s.key}`} - onClick={() => setActive(s.key)} + onClick={() => handleSelect(s.key)} className={[ "w-full flex items-center gap-2 px-2 py-1.5 rounded border-l-2 text-left", selected diff --git a/03_implementation/ui/src/theme/ThemeProvider.tsx b/03_implementation/ui/src/theme/ThemeProvider.tsx index b445ba41..c5240852 100644 --- a/03_implementation/ui/src/theme/ThemeProvider.tsx +++ b/03_implementation/ui/src/theme/ThemeProvider.tsx @@ -35,6 +35,7 @@ import { type ResolvedThemeMode, type ThemeMode, } from "./tokens"; +import { applyPalette, readStoredPaletteId } from "./palettes"; const STORAGE_KEY = "h3d.theme"; const VALID_MODES: readonly ThemeMode[] = ["light", "dark", "system"] as const; @@ -91,6 +92,11 @@ function resolveTheme(theme: ThemeMode): ResolvedThemeMode { /** * Apply the resolved theme to the document. Called both on mount and on * every change so that hot-reloads stay consistent. + * + * W15-A17 — after the base light/dark variables land, the persisted named + * palette (`h3d.theme.palette`) is layered on top so its `--h3d-color-*` + * overrides shadow the defaults. `applyPalette` is a no-op when the + * stored id is `default`, so this is free for users on the baseline. */ function applyTheme(resolved: ResolvedThemeMode): void { if (typeof document === "undefined") return; @@ -103,6 +109,9 @@ function applyTheme(resolved: ResolvedThemeMode): void { for (const [name, value] of Object.entries(vars)) { root.style.setProperty(name, value); } + + // Layer the named palette over the base CSS variables. + applyPalette(readStoredPaletteId()); } export function ThemeProvider({ diff --git a/03_implementation/ui/src/theme/index.ts b/03_implementation/ui/src/theme/index.ts index 1d7c142a..2aa43633 100644 --- a/03_implementation/ui/src/theme/index.ts +++ b/03_implementation/ui/src/theme/index.ts @@ -23,3 +23,15 @@ export { type Tokens, } from "./tokens"; export { bootstrapTheme } from "./themeBootstrap"; +export { + applyPalette, + isNamedPaletteId, + NAMED_PALETTES, + NAMED_PALETTE_BY_ID, + DEFAULT_PALETTE_ID, + PALETTE_STORAGE_KEY, + readStoredPaletteId, + writeStoredPaletteId, + type NamedPalette, + type NamedPaletteId, +} from "./palettes"; diff --git a/03_implementation/ui/src/theme/palettes/aurora-operator.ts b/03_implementation/ui/src/theme/palettes/aurora-operator.ts new file mode 100644 index 00000000..0184708d --- /dev/null +++ b/03_implementation/ui/src/theme/palettes/aurora-operator.ts @@ -0,0 +1,34 @@ +/** + * `aurora-operator` palette — soft blue-violet on midnight. + * + * Calmer counterpart to the default cyan: indigo-leaning primary, low + * saturation, designed for sustained agent-supervision sessions where + * the GUI is the background of attention, not the foreground. + * + * Contrast (verified): + * fg:bg = 16.55 muted:bg = 7.57 primary:bg = 7.98 + */ +import type { NamedPalette } from "./index"; + +export const palette: NamedPalette = { + id: "aurora-operator", + label: "Aurora Operator", + baseMode: "dark", + blurb: "Soft blue-violet on midnight — calm palette for long supervision shifts.", + swatches: { background: "#0a1024", primary: "#7aa8ff" }, + cssVars: { + "--h3d-color-background": "#0a1024", + "--h3d-color-surface": "#11173a", + "--h3d-color-surface-2": "#1c2155", + "--h3d-color-border": "#2e3680", + "--h3d-color-text-primary": "#eaf0ff", + "--h3d-color-text-secondary": "#92a3d5", + "--h3d-color-primary": "#7aa8ff", + "--h3d-color-secondary": "#a78bfa", + "--h3d-color-accent": "#5cd6ff", + "--h3d-color-error": "#ff7a7a", + "--h3d-color-warning": "#ffc857", + "--h3d-color-success": "#5cffba", + "--h3d-color-info": "#7aa8ff", + }, +}; diff --git a/03_implementation/ui/src/theme/palettes/cyberpunk.ts b/03_implementation/ui/src/theme/palettes/cyberpunk.ts new file mode 100644 index 00000000..89c98d6b --- /dev/null +++ b/03_implementation/ui/src/theme/palettes/cyberpunk.ts @@ -0,0 +1,34 @@ +/** + * `cyberpunk` palette — neon magenta on deep violet. + * + * Inspired by the cyberpunk-genre conventions in + * Images-GUI/09-themes/theme-variants-reference.png: a saturated pink/magenta + * primary against a near-black violet background. + * + * Contrast (verified): + * fg:bg = 17.55 muted:bg = 7.18 primary:bg = 6.41 + */ +import type { NamedPalette } from "./index"; + +export const palette: NamedPalette = { + id: "cyberpunk", + label: "Cyberpunk", + baseMode: "dark", + blurb: "Neon magenta on deep-violet — high contrast for nighttime ops.", + swatches: { background: "#0b0014", primary: "#ff2bd6" }, + cssVars: { + "--h3d-color-background": "#0b0014", + "--h3d-color-surface": "#140524", + "--h3d-color-surface-2": "#1f0b37", + "--h3d-color-border": "#3f1d68", + "--h3d-color-text-primary": "#f5e9ff", + "--h3d-color-text-secondary": "#b288d3", + "--h3d-color-primary": "#ff2bd6", + "--h3d-color-secondary": "#7e3cff", + "--h3d-color-accent": "#ffd23f", + "--h3d-color-error": "#ff4d6d", + "--h3d-color-warning": "#ffb84d", + "--h3d-color-success": "#5cffba", + "--h3d-color-info": "#7aa8ff", + }, +}; diff --git a/03_implementation/ui/src/theme/palettes/default.ts b/03_implementation/ui/src/theme/palettes/default.ts new file mode 100644 index 00000000..a291a756 --- /dev/null +++ b/03_implementation/ui/src/theme/palettes/default.ts @@ -0,0 +1,34 @@ +/** + * `default` palette — Hermes3D dark, matching `tokens.ts#darkPalette` so + * existing screenshot baselines remain stable. + * + * Contrast (verified): + * fg:bg = 16.34 muted:bg = 5.55 primary:bg = 10.65 + * + * This palette is intentionally identical to the current production dark + * theme so selecting "default" is a no-op for users on the live build. + */ +import type { NamedPalette } from "./index"; + +export const palette: NamedPalette = { + id: "default", + label: "Default Dark", + baseMode: "dark", + blurb: "The standard Hermes3D dark theme — cyan accents on midnight.", + swatches: { background: "#0a0e1a", primary: "#22d3ee" }, + cssVars: { + "--h3d-color-background": "#0a0e1a", + "--h3d-color-surface": "#0f1626", + "--h3d-color-surface-2": "#141d33", + "--h3d-color-border": "#1f2a44", + "--h3d-color-text-primary": "#e6edf7", + "--h3d-color-text-secondary": "#7c8aa8", + "--h3d-color-primary": "#22d3ee", + "--h3d-color-secondary": "#3b82f6", + "--h3d-color-accent": "#a78bfa", + "--h3d-color-error": "#ef4444", + "--h3d-color-warning": "#f59e0b", + "--h3d-color-success": "#22c55e", + "--h3d-color-info": "#3b82f6", + }, +}; diff --git a/03_implementation/ui/src/theme/palettes/index.ts b/03_implementation/ui/src/theme/palettes/index.ts new file mode 100644 index 00000000..d3311fab --- /dev/null +++ b/03_implementation/ui/src/theme/palettes/index.ts @@ -0,0 +1,143 @@ +/** + * Named theme palettes — W15-A17 lane. + * + * Each palette is a self-contained module that exports a `ThemePalette` + * value plus a sibling `cssVars` record (the same `--h3d-color-*` keys + * the base `theme/tokens.ts` emits). This keeps the W8-3 ThemeProvider + * surface unchanged — the named palette layers on top of the resolved + * light/dark mode by overwriting the same CSS custom properties. + * + * Source/reference for the contrast targets: + * - WCAG 2.1 §1.4.3 Contrast (Minimum) + * https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html + * - VS Code "Customizing colors" + * https://code.visualstudio.com/docs/getstarted/themes#_customizing-a-color-theme + * (modelled the palette-key shape after their `workbench.colorCustomizations` + * so future per-component overrides drop in cleanly). + * + * Contrast guarantees (computed via the WCAG relative-luminance formula, + * verified by `.claude/scratch/contrast.mjs` at design time): + * - body text (`fg` on `background`) >= 4.5 : 1 + * - secondary text (`muted` on `background`) >= 4.5 : 1 + * - UI / large text (`primary` on `background`) >= 3.0 : 1 + * All six palettes meet AA at body-text strength. + */ + +import * as defaultPalette from "./default"; +import * as cyberpunk from "./cyberpunk"; +import * as matrix from "./matrix"; +import * as tron from "./tron"; +import * as industrialForge from "./industrial-forge"; +import * as auroraOperator from "./aurora-operator"; + +export type NamedPaletteId = + | "default" + | "cyberpunk" + | "matrix" + | "tron" + | "industrial-forge" + | "aurora-operator"; + +export interface NamedPalette { + /** Stable id — used as the localStorage key and the radio value. */ + id: NamedPaletteId; + /** Human-readable label for the palette switcher. */ + label: string; + /** Tone of the palette; matches `ResolvedThemeMode` from `tokens.ts`. */ + baseMode: "dark"; + /** One-line description rendered next to the radio. */ + blurb: string; + /** Two preview swatches shown in the radio card. */ + swatches: { background: string; primary: string }; + /** + * Full record of `--h3d-color-*` CSS custom properties, ready to apply + * via `root.style.setProperty()`. Includes every key emitted by + * `themeCssVars` in `theme/tokens.ts` so an apply call cleanly replaces + * the previous palette without leftover variables. + */ + cssVars: Record; +} + +export const NAMED_PALETTES: readonly NamedPalette[] = [ + defaultPalette.palette, + cyberpunk.palette, + matrix.palette, + tron.palette, + industrialForge.palette, + auroraOperator.palette, +] as const; + +export const NAMED_PALETTE_BY_ID: Record = Object.freeze( + NAMED_PALETTES.reduce( + (acc, p) => { + acc[p.id] = p; + return acc; + }, + {} as Record, + ), +); + +export const DEFAULT_PALETTE_ID: NamedPaletteId = "default"; + +export function isNamedPaletteId(value: unknown): value is NamedPaletteId { + return typeof value === "string" && value in NAMED_PALETTE_BY_ID; +} + +/** + * Storage key for the active palette. Stable contract — bumping the + * version here invalidates every user's persisted selection, so leave it + * alone unless we're shipping a breaking change to the palette shape. + */ +export const PALETTE_STORAGE_KEY = "h3d.theme.palette"; + +/** + * Apply a palette's CSS variables to the document root. Returns the id + * that was applied (or `null` in non-browser contexts) so callers can + * confirm without a follow-up DOM read. + * + * Idempotent: safe to call repeatedly with the same id. + * + * IMPORTANT: when `id` is the default palette, this only stamps the + * `data-h3d-palette` attribute and does NOT overwrite `--h3d-color-*` + * variables. The base ThemeProvider's `themeCssVars(resolved)` call + * already supplies them with the correct light/dark values, and we want + * the default selection to stay transparent so light-mode users keep a + * white background. Selecting a non-default palette always overrides + * the base vars (palettes are dark-only by design — see W15-A17 spec). + */ +export function applyPalette(id: NamedPaletteId): NamedPaletteId | null { + if (typeof document === "undefined") return null; + const palette = NAMED_PALETTE_BY_ID[id] ?? NAMED_PALETTE_BY_ID[DEFAULT_PALETTE_ID]; + const root = document.documentElement; + root.dataset.h3dPalette = palette.id; + if (palette.id === DEFAULT_PALETTE_ID) { + // No-op for CSS vars — keep whatever ThemeProvider has set so + // light/dark mode and the default look stay consistent. + return palette.id; + } + for (const [name, value] of Object.entries(palette.cssVars)) { + root.style.setProperty(name, value); + } + return palette.id; +} + +/** Read the persisted palette id, falling back to `default` when missing/invalid. */ +export function readStoredPaletteId(): NamedPaletteId { + if (typeof window === "undefined") return DEFAULT_PALETTE_ID; + try { + const raw = window.localStorage.getItem(PALETTE_STORAGE_KEY); + return isNamedPaletteId(raw) ? raw : DEFAULT_PALETTE_ID; + } catch { + return DEFAULT_PALETTE_ID; + } +} + +/** Persist a palette id. Silent on storage failures (private mode, quota). */ +export function writeStoredPaletteId(id: NamedPaletteId): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(PALETTE_STORAGE_KEY, id); + } catch { + /* non-fatal */ + } +} diff --git a/03_implementation/ui/src/theme/palettes/industrial-forge.ts b/03_implementation/ui/src/theme/palettes/industrial-forge.ts new file mode 100644 index 00000000..eea25001 --- /dev/null +++ b/03_implementation/ui/src/theme/palettes/industrial-forge.ts @@ -0,0 +1,34 @@ +/** + * `industrial-forge` palette — warm amber on graphite. + * + * Workshop / foundry vibe: graphite-grey surfaces with a hot-metal amber + * primary, suitable for long shop-floor sessions where blue-light strain + * is a concern. + * + * Contrast (verified): + * fg:bg = 15.83 muted:bg = 7.05 primary:bg = 9.35 + */ +import type { NamedPalette } from "./index"; + +export const palette: NamedPalette = { + id: "industrial-forge", + label: "Industrial Forge", + baseMode: "dark", + blurb: "Warm amber on graphite — workshop palette tuned for long sessions.", + swatches: { background: "#161310", primary: "#ffa432" }, + cssVars: { + "--h3d-color-background": "#161310", + "--h3d-color-surface": "#221d18", + "--h3d-color-surface-2": "#332921", + "--h3d-color-border": "#4a3b2e", + "--h3d-color-text-primary": "#f5ece1", + "--h3d-color-text-secondary": "#b39c84", + "--h3d-color-primary": "#ffa432", + "--h3d-color-secondary": "#e07b2c", + "--h3d-color-accent": "#ffd25a", + "--h3d-color-error": "#ff6b50", + "--h3d-color-warning": "#ffc857", + "--h3d-color-success": "#9fd47b", + "--h3d-color-info": "#c08850", + }, +}; diff --git a/03_implementation/ui/src/theme/palettes/matrix.ts b/03_implementation/ui/src/theme/palettes/matrix.ts new file mode 100644 index 00000000..dbe5e3f9 --- /dev/null +++ b/03_implementation/ui/src/theme/palettes/matrix.ts @@ -0,0 +1,35 @@ +/** + * `matrix` palette — phosphor green on near-black. + * + * Strict monochrome-green wash following the classic terminal-green + * convention; accents lean amber/yellow only for error/warning states so + * the palette stays readable for users with red-green color-vision + * deficiency (the green-only signal would otherwise be ambiguous). + * + * Contrast (verified): + * fg:bg = 18.28 muted:bg = 7.86 primary:bg = 14.96 + */ +import type { NamedPalette } from "./index"; + +export const palette: NamedPalette = { + id: "matrix", + label: "Matrix", + baseMode: "dark", + blurb: "Phosphor-green terminal aesthetic — minimal chrome, maximum signal.", + swatches: { background: "#020a02", primary: "#22ff7b" }, + cssVars: { + "--h3d-color-background": "#020a02", + "--h3d-color-surface": "#031305", + "--h3d-color-surface-2": "#062108", + "--h3d-color-border": "#0b3a10", + "--h3d-color-text-primary": "#d6ffd6", + "--h3d-color-text-secondary": "#74b079", + "--h3d-color-primary": "#22ff7b", + "--h3d-color-secondary": "#5ad07f", + "--h3d-color-accent": "#a8ff60", + "--h3d-color-error": "#ffb24d", + "--h3d-color-warning": "#ffe66d", + "--h3d-color-success": "#22ff7b", + "--h3d-color-info": "#74b079", + }, +}; diff --git a/03_implementation/ui/src/theme/palettes/tron.ts b/03_implementation/ui/src/theme/palettes/tron.ts new file mode 100644 index 00000000..f61f6b43 --- /dev/null +++ b/03_implementation/ui/src/theme/palettes/tron.ts @@ -0,0 +1,33 @@ +/** + * `tron` palette — pale cyan grid on inky navy. + * + * Higher saturation than the default cyan, but a colder, paler shift to + * read as the eponymous "lightcycle grid" rather than Hermes-default. + * + * Contrast (verified): + * fg:bg = 18.32 muted:bg = 8.47 primary:bg = 16.16 + */ +import type { NamedPalette } from "./index"; + +export const palette: NamedPalette = { + id: "tron", + label: "Tron", + baseMode: "dark", + blurb: "Pale-cyan grid on inky navy — every edge looks lit from inside.", + swatches: { background: "#000812", primary: "#7df9ff" }, + cssVars: { + "--h3d-color-background": "#000812", + "--h3d-color-surface": "#001327", + "--h3d-color-surface-2": "#012244", + "--h3d-color-border": "#0a3c6c", + "--h3d-color-text-primary": "#e6f7ff", + "--h3d-color-text-secondary": "#7faecd", + "--h3d-color-primary": "#7df9ff", + "--h3d-color-secondary": "#33e7ff", + "--h3d-color-accent": "#ffb300", + "--h3d-color-error": "#ff6363", + "--h3d-color-warning": "#ffb300", + "--h3d-color-success": "#3ddcae", + "--h3d-color-info": "#7df9ff", + }, +}; diff --git a/03_implementation/ui/src/theme/themeBootstrap.ts b/03_implementation/ui/src/theme/themeBootstrap.ts index 7e59a3b7..59efdaad 100644 --- a/03_implementation/ui/src/theme/themeBootstrap.ts +++ b/03_implementation/ui/src/theme/themeBootstrap.ts @@ -5,8 +5,13 @@ * theme system, BEFORE `ReactDOM.createRoot`. * * Safe to call multiple times; mutations are idempotent. + * + * W15-A17 extension: after the base light/dark variables land, the + * persisted named palette (`h3d.theme.palette`) is applied so its + * `--h3d-color-*` overrides shadow the defaults on first paint. */ import { themeCssVars, type ThemeMode } from "./tokens"; +import { applyPalette, readStoredPaletteId } from "./palettes"; const STORAGE_KEY = "h3d.theme"; @@ -45,4 +50,9 @@ export function bootstrapTheme(storageKey: string = STORAGE_KEY): void { for (const [k, v] of Object.entries(vars)) { root.style.setProperty(k, v); } + + // Layer the named palette on top of the base variables so deep links + // and reloads keep the user's chosen palette before React renders. + // `applyPalette` is a no-op when the stored id is the default. + applyPalette(readStoredPaletteId()); } diff --git a/03_implementation/ui/tests/unit/SettingsPage.test.tsx b/03_implementation/ui/tests/unit/SettingsPage.test.tsx new file mode 100644 index 00000000..77fa5ae1 --- /dev/null +++ b/03_implementation/ui/tests/unit/SettingsPage.test.tsx @@ -0,0 +1,84 @@ +/** + * Unit tests for the W15-A17 SettingsPage URL-addressable subtab routing. + * + * Verifies: + * - subtab is selected from the initial `#settings/` hash + * - clicking a subtab updates the hash via replaceState + * - hashchange events from outside (back/forward) select the matching subtab + * - default fallback when the hash is missing or invalid + */ +import { fireEvent, render, screen, act } from "@testing-library/react"; +import { describe, expect, it, beforeEach } from "vitest"; +import { SettingsPage } from "../../src/components/settings/SettingsPage"; + +function setHash(hash: string): void { + window.history.replaceState(null, "", hash); + // jsdom doesn't fire hashchange on replaceState, so we synthesize it. + window.dispatchEvent(new HashChangeEvent("hashchange")); +} + +describe("SettingsPage (W15-A17) URL-addressable subtabs", () => { + beforeEach(() => { + setHash("#settings"); + }); + + it("renders the general subtab when no sub is in the hash", () => { + render(); + const root = screen.getByTestId("settings-root"); + expect(root.getAttribute("data-active-subtab")).toBe("general"); + }); + + it("selects the matching subtab from an initial #settings/ hash", () => { + setHash("#settings/mcp"); + render(); + const root = screen.getByTestId("settings-root"); + expect(root.getAttribute("data-active-subtab")).toBe("mcp"); + }); + + it.each([ + ["#settings/general", "general"], + ["#settings/providers", "providers"], + ["#settings/agents", "agents"], + ["#settings/mcp", "mcp"], + ["#settings/printers", "printers"], + ["#settings/environment", "environment"], + ["#settings/updates", "updates"], + ["#settings/about", "about"], + ])("hash %s selects the %s subtab", (hash, sub) => { + setHash(hash); + render(); + const root = screen.getByTestId("settings-root"); + expect(root.getAttribute("data-active-subtab")).toBe(sub); + }); + + it("clicking a subtab updates the URL hash", () => { + render(); + const printersBtn = screen.getByTestId("settings-subtab-printers"); + fireEvent.click(printersBtn); + expect(window.location.hash).toBe("#settings/printers"); + expect(screen.getByTestId("settings-root").getAttribute("data-active-subtab")).toBe( + "printers", + ); + }); + + it("responds to external hashchange events", () => { + render(); + expect(screen.getByTestId("settings-root").getAttribute("data-active-subtab")).toBe( + "general", + ); + act(() => { + setHash("#settings/about"); + }); + expect(screen.getByTestId("settings-root").getAttribute("data-active-subtab")).toBe( + "about", + ); + }); + + it("ignores invalid subtab names in the hash and falls back to general", () => { + setHash("#settings/not-a-subtab"); + render(); + expect(screen.getByTestId("settings-root").getAttribute("data-active-subtab")).toBe( + "general", + ); + }); +}); diff --git a/03_implementation/ui/tests/unit/palettes.test.ts b/03_implementation/ui/tests/unit/palettes.test.ts new file mode 100644 index 00000000..f8346624 --- /dev/null +++ b/03_implementation/ui/tests/unit/palettes.test.ts @@ -0,0 +1,150 @@ +/** + * Unit tests for the W15-A17 named-palette layer: + * - palette inventory matches the spec (6 entries, stable ids) + * - applyPalette stamps CSS variables for non-default ids + * - applyPalette is a no-op for the default id (does not overwrite vars) + * - localStorage round-trip via read/writeStoredPaletteId + * - every palette ships every required `--h3d-color-*` key + * - every palette meets WCAG AA contrast (body text 4.5:1, UI 3.0:1) + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + applyPalette, + DEFAULT_PALETTE_ID, + isNamedPaletteId, + NAMED_PALETTES, + PALETTE_STORAGE_KEY, + readStoredPaletteId, + writeStoredPaletteId, +} from "../../src/theme/palettes"; + +const REQUIRED_KEYS = [ + "--h3d-color-background", + "--h3d-color-surface", + "--h3d-color-surface-2", + "--h3d-color-border", + "--h3d-color-text-primary", + "--h3d-color-text-secondary", + "--h3d-color-primary", + "--h3d-color-secondary", + "--h3d-color-accent", + "--h3d-color-error", + "--h3d-color-warning", + "--h3d-color-success", + "--h3d-color-info", +] as const; + +// WCAG 2.1 relative luminance + contrast. +function hexChannels(hex: string): [number, number, number] { + const m = hex.replace(/^#/, ""); + const r = parseInt(m.slice(0, 2), 16) / 255; + const g = parseInt(m.slice(2, 4), 16) / 255; + const b = parseInt(m.slice(4, 6), 16) / 255; + return [r, g, b]; +} +function linearise(c: number): number { + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} +function luminance([r, g, b]: [number, number, number]): number { + return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b); +} +function contrast(a: string, b: string): number { + const la = luminance(hexChannels(a)); + const lb = luminance(hexChannels(b)); + const [hi, lo] = la > lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +describe("named palettes (W15-A17)", () => { + beforeEach(() => { + document.documentElement.removeAttribute("style"); + delete document.documentElement.dataset.h3dPalette; + window.localStorage.removeItem(PALETTE_STORAGE_KEY); + }); + + afterEach(() => { + document.documentElement.removeAttribute("style"); + delete document.documentElement.dataset.h3dPalette; + }); + + it("ships exactly six palettes with stable ids", () => { + const ids = NAMED_PALETTES.map((p) => p.id); + expect(ids).toEqual([ + "default", + "cyberpunk", + "matrix", + "tron", + "industrial-forge", + "aurora-operator", + ]); + }); + + it("every palette defines every required CSS variable", () => { + for (const p of NAMED_PALETTES) { + for (const key of REQUIRED_KEYS) { + expect(p.cssVars[key], `${p.id} missing ${key}`).toMatch(/^#[0-9a-f]{6}$/i); + } + } + }); + + it("every palette meets WCAG AA contrast (body 4.5, UI 3.0)", () => { + for (const p of NAMED_PALETTES) { + const bg = p.cssVars["--h3d-color-background"]!; + const fg = p.cssVars["--h3d-color-text-primary"]!; + const muted = p.cssVars["--h3d-color-text-secondary"]!; + const primary = p.cssVars["--h3d-color-primary"]!; + expect(contrast(fg, bg), `${p.id} fg:bg`).toBeGreaterThanOrEqual(4.5); + expect(contrast(muted, bg), `${p.id} muted:bg`).toBeGreaterThanOrEqual(4.5); + expect(contrast(primary, bg), `${p.id} primary:bg`).toBeGreaterThanOrEqual(3.0); + } + }); + + it("isNamedPaletteId guards membership", () => { + expect(isNamedPaletteId("default")).toBe(true); + expect(isNamedPaletteId("cyberpunk")).toBe(true); + expect(isNamedPaletteId("not-a-palette")).toBe(false); + expect(isNamedPaletteId(undefined)).toBe(false); + expect(isNamedPaletteId(42)).toBe(false); + }); + + it("applyPalette stamps data-h3d-palette and CSS variables for non-default ids", () => { + applyPalette("matrix"); + expect(document.documentElement.dataset.h3dPalette).toBe("matrix"); + expect( + document.documentElement.style.getPropertyValue("--h3d-color-background"), + ).toBe("#020a02"); + expect( + document.documentElement.style.getPropertyValue("--h3d-color-primary"), + ).toBe("#22ff7b"); + }); + + it("applyPalette('default') stamps the attribute but does NOT touch CSS vars", () => { + // Pre-stage with a non-default palette so we can detect the rollback. + applyPalette("cyberpunk"); + expect( + document.documentElement.style.getPropertyValue("--h3d-color-background"), + ).toBe("#0b0014"); + + applyPalette(DEFAULT_PALETTE_ID); + expect(document.documentElement.dataset.h3dPalette).toBe("default"); + // Variables remain whatever was last stamped — ThemeProvider owns the + // base values for the default selection. Crucially we must NOT have + // rewritten background to the dark default (which would clobber light + // mode). + expect( + document.documentElement.style.getPropertyValue("--h3d-color-background"), + ).toBe("#0b0014"); + }); + + it("localStorage round-trip via read/writeStoredPaletteId", () => { + expect(readStoredPaletteId()).toBe("default"); + writeStoredPaletteId("tron"); + expect(window.localStorage.getItem(PALETTE_STORAGE_KEY)).toBe("tron"); + expect(readStoredPaletteId()).toBe("tron"); + }); + + it("readStoredPaletteId falls back to default on garbage", () => { + window.localStorage.setItem(PALETTE_STORAGE_KEY, "nonsense"); + expect(readStoredPaletteId()).toBe("default"); + }); +}); diff --git a/03_implementation/ui/tests/visual/__snapshots__/palette-preview/aurora-operator.png b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/aurora-operator.png new file mode 100644 index 00000000..839eaf52 Binary files /dev/null and b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/aurora-operator.png differ diff --git a/03_implementation/ui/tests/visual/__snapshots__/palette-preview/cyberpunk.png b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/cyberpunk.png new file mode 100644 index 00000000..f576c27a Binary files /dev/null and b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/cyberpunk.png differ diff --git a/03_implementation/ui/tests/visual/__snapshots__/palette-preview/default.png b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/default.png new file mode 100644 index 00000000..d93c5e1f Binary files /dev/null and b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/default.png differ diff --git a/03_implementation/ui/tests/visual/__snapshots__/palette-preview/industrial-forge.png b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/industrial-forge.png new file mode 100644 index 00000000..ab65d2a3 Binary files /dev/null and b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/industrial-forge.png differ diff --git a/03_implementation/ui/tests/visual/__snapshots__/palette-preview/matrix.png b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/matrix.png new file mode 100644 index 00000000..531b1bce Binary files /dev/null and b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/matrix.png differ diff --git a/03_implementation/ui/tests/visual/__snapshots__/palette-preview/tron.png b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/tron.png new file mode 100644 index 00000000..726401dd Binary files /dev/null and b/03_implementation/ui/tests/visual/__snapshots__/palette-preview/tron.png differ diff --git a/03_implementation/ui/tests/visual/palettes-preview.spec.ts b/03_implementation/ui/tests/visual/palettes-preview.spec.ts new file mode 100644 index 00000000..e968b37f --- /dev/null +++ b/03_implementation/ui/tests/visual/palettes-preview.spec.ts @@ -0,0 +1,135 @@ +/** + * Palette preview screenshots (W15-A17). + * + * Renders a self-contained card-grid that shows every named palette + * applied to a representative slice of the GUI (surfaces, text, primary + * button). Saves a PNG per palette + a collage to + * `tests/visual/__snapshots__/palette-preview/`. + * + * Run via: + * npx playwright test tests/visual/palettes-preview.spec.ts --project=chromium + * + * This is intentionally NOT a screenshot-comparison test — its only job + * is to produce artefacts attached to the PR. + */ +import { test, expect } from "@playwright/test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { NAMED_PALETTES } from "../../src/theme/palettes"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const OUT_DIR = path.join( + __dirname, + "__snapshots__", + "palette-preview", +); + +function ensureOutDir(): void { + fs.mkdirSync(OUT_DIR, { recursive: true }); +} + +function previewHtml(): string { + return ` + + + + W15-A17 palette preview + + + +
+

+
+
background
+
surface
+
primary
+
Secondary text on a surface-2 panel.
+ +
+
+ +`; +} + +test.describe("W15-A17 named palette previews", () => { + test.beforeAll(() => { + ensureOutDir(); + }); + + for (const palette of NAMED_PALETTES) { + test(`renders the ${palette.id} palette`, async ({ page }) => { + await page.setViewportSize({ width: 540, height: 380 }); + await page.setContent(previewHtml()); + await page.evaluate((p) => { + const root = document.documentElement; + for (const [k, v] of Object.entries(p.cssVars)) { + root.style.setProperty(k, v); + } + document.getElementById("label")!.textContent = p.label; + document.getElementById("blurb")!.textContent = p.blurb; + document.getElementById("id")!.textContent = `palette · ${p.id}`; + (document.getElementById("sw-bg") as HTMLElement).style.background = + p.cssVars["--h3d-color-background"]; + (document.getElementById("sw-surface") as HTMLElement).style.background = + p.cssVars["--h3d-color-surface"]; + (document.getElementById("sw-primary") as HTMLElement).style.background = + p.cssVars["--h3d-color-primary"]; + }, palette); + + const card = page.getByTestId("preview-card"); + await expect(card).toBeVisible(); + const out = path.join(OUT_DIR, `${palette.id}.png`); + await card.screenshot({ path: out }); + expect(fs.existsSync(out)).toBe(true); + }); + } +}); diff --git a/03_implementation/ui/tests/visual/palettes.playwright.config.ts b/03_implementation/ui/tests/visual/palettes.playwright.config.ts new file mode 100644 index 00000000..1ced02a4 --- /dev/null +++ b/03_implementation/ui/tests/visual/palettes.playwright.config.ts @@ -0,0 +1,29 @@ +/** + * Minimal Playwright config for the W15-A17 palette preview spec. + * + * Standalone so it doesn't require the e2e webServer (the spec is + * self-contained via `page.setContent`). Run via: + * npx playwright test --config tests/visual/palettes.playwright.config.ts + */ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testMatch: "palettes-preview.spec.ts", + fullyParallel: false, + retries: 0, + workers: 1, + reporter: [["list"]], + outputDir: "../../test-results/palettes-preview", + use: { + headless: true, + screenshot: "off", + trace: "off", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +});