From dd022f808c5ef9112962c4554023c84a3b782dc0 Mon Sep 17 00:00:00 2001 From: Ghenghis <6685932+Ghenghis@users.noreply.github.com> Date: Sun, 10 May 2026 15:28:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20W15-A17=20settings=20=E2=80=94=20UR?= =?UTF-8?q?L-addressable=20subtabs=20+=206=20theme=20palettes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL-addressable subtabs (8): #settings/general, /providers, /agents, /mcp, /printers, /environment, /updates, /about — clicks rewrite via replaceState, hashchange + popstate re-sync the subtab so deep links and back/forward navigate cleanly. Named theme palettes (6): default, cyberpunk, matrix, tron, industrial-forge, aurora-operator. Each palette ships all 13 --h3d-color-* vars; live-preview applies them immediately, Save persists to localStorage[h3d.theme.palette]. Backend /api/settings/themes is owned by A20 — palette is preview-only until it ships. ThemeProvider re-applies the named palette after every light/dark flip; the `default` id is a transparent no-op so light-mode users keep white backgrounds. WCAG AA verified for every palette: fg:bg >= 15.83 muted:bg >= 5.55 primary:bg >= 6.41 Tests: 21 new unit tests (8 hash routes + 13 palette guarantees) plus 6 Playwright preview screenshots under tests/visual/__snapshots__/. Sources: - WCAG 2.1 contrast: https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html - VS Code color customizations: https://code.visualstudio.com/docs/getstarted/themes Task: W15-A17-SETTINGS-THEMES-2026-05-10 Co-Authored-By: Claude Opus 4.7 (1M context) --- 03_implementation/ui/src/app/store.ts | 53 +++++++ .../src/components/settings/GeneralSubtab.tsx | 108 ++++++++++++- .../src/components/settings/SettingsPage.tsx | 113 +++++++++++-- .../ui/src/theme/ThemeProvider.tsx | 9 ++ 03_implementation/ui/src/theme/index.ts | 12 ++ .../ui/src/theme/palettes/aurora-operator.ts | 34 ++++ .../ui/src/theme/palettes/cyberpunk.ts | 34 ++++ .../ui/src/theme/palettes/default.ts | 34 ++++ .../ui/src/theme/palettes/index.ts | 143 +++++++++++++++++ .../ui/src/theme/palettes/industrial-forge.ts | 34 ++++ .../ui/src/theme/palettes/matrix.ts | 35 ++++ .../ui/src/theme/palettes/tron.ts | 33 ++++ .../ui/src/theme/themeBootstrap.ts | 10 ++ .../ui/tests/unit/SettingsPage.test.tsx | 84 ++++++++++ .../ui/tests/unit/palettes.test.ts | 150 ++++++++++++++++++ .../palette-preview/aurora-operator.png | Bin 0 -> 12046 bytes .../palette-preview/cyberpunk.png | Bin 0 -> 10940 bytes .../__snapshots__/palette-preview/default.png | Bin 0 -> 10873 bytes .../palette-preview/industrial-forge.png | Bin 0 -> 11987 bytes .../__snapshots__/palette-preview/matrix.png | Bin 0 -> 10369 bytes .../__snapshots__/palette-preview/tron.png | Bin 0 -> 9889 bytes .../ui/tests/visual/palettes-preview.spec.ts | 135 ++++++++++++++++ .../visual/palettes.playwright.config.ts | 29 ++++ 23 files changed, 1032 insertions(+), 18 deletions(-) create mode 100644 03_implementation/ui/src/theme/palettes/aurora-operator.ts create mode 100644 03_implementation/ui/src/theme/palettes/cyberpunk.ts create mode 100644 03_implementation/ui/src/theme/palettes/default.ts create mode 100644 03_implementation/ui/src/theme/palettes/index.ts create mode 100644 03_implementation/ui/src/theme/palettes/industrial-forge.ts create mode 100644 03_implementation/ui/src/theme/palettes/matrix.ts create mode 100644 03_implementation/ui/src/theme/palettes/tron.ts create mode 100644 03_implementation/ui/tests/unit/SettingsPage.test.tsx create mode 100644 03_implementation/ui/tests/unit/palettes.test.ts create mode 100644 03_implementation/ui/tests/visual/__snapshots__/palette-preview/aurora-operator.png create mode 100644 03_implementation/ui/tests/visual/__snapshots__/palette-preview/cyberpunk.png create mode 100644 03_implementation/ui/tests/visual/__snapshots__/palette-preview/default.png create mode 100644 03_implementation/ui/tests/visual/__snapshots__/palette-preview/industrial-forge.png create mode 100644 03_implementation/ui/tests/visual/__snapshots__/palette-preview/matrix.png create mode 100644 03_implementation/ui/tests/visual/__snapshots__/palette-preview/tron.png create mode 100644 03_implementation/ui/tests/visual/palettes-preview.spec.ts create mode 100644 03_implementation/ui/tests/visual/palettes.playwright.config.ts 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 0000000000000000000000000000000000000000..839eaf52894c852400d324d112b5f923aad6e35a GIT binary patch literal 12046 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f576c27ad1f6c7677228ac9e62e439a6ecac6552 GIT binary patch literal 10940 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 S5<&fi03aqJEnFd}>-Qg3uhixM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d93c5e1f2907e89c1abad07071962f4c815da041 GIT binary patch literal 10873 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ab65d2a322532cef96ce3405e771881f5582e21a GIT binary patch literal 11987 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..531b1bce9725bfa5d8f0bb100160472c0f177cc5 GIT binary patch literal 10369 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 b + + + + 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"] }, + }, + ], +});