-
- );
-}
-
-function Ctl({
- s,
- label,
- value,
- onChange,
-}: {
- s: Slider;
- label: string;
- value: number;
- onChange: (v: number) => void;
-}) {
- // The label names the number box, and the range beside it says the same thing: both controls
- // are one setting, and the label was not tied to either before (a screen reader read "spin
- // button" and "slider" with no name at all).
- const id = useId();
- return (
-
- );
-}
diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx
index 7a0f655e..3d2878bd 100644
--- a/frontend/src/components/PrefsProvider.tsx
+++ b/frontend/src/components/PrefsProvider.tsx
@@ -12,8 +12,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { api, type Me } from "../lib/api";
import {
applyTheme,
- DEFAULT_THEME,
loadLocalTheme,
+ normalizeTheme,
saveLocalTheme,
type ThemePrefs,
} from "../lib/theme";
@@ -28,7 +28,7 @@ const PERF_KEY = LS.perfMode;
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
-// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs() (prefsContext.ts).
+// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
@@ -44,7 +44,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
const [seed] = useState(() => {
const prefs = qc.getQueryData(qk.me())?.preferences;
return {
- theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
+ theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
performanceMode:
typeof prefs?.performanceMode === "boolean"
? prefs.performanceMode
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 6da08fbd..c69407f3 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -213,16 +213,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
>
-
- setTheme({ ...theme, showTuner: v })}
- />
-
diff --git a/frontend/src/design-lab/batchB.tsx b/frontend/src/design-lab/batchB.tsx
index 97700769..b3418d02 100644
--- a/frontend/src/design-lab/batchB.tsx
+++ b/frontend/src/design-lab/batchB.tsx
@@ -21,17 +21,9 @@ import {
Youtube,
} from "lucide-react";
import { Btn } from "./batchA";
-import { hexContrast, hueGap } from "./labColor";
+import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
-import {
- FADE_ABOVE_PANEL,
- FADE_BELOW,
- FADE_BELOW_PANEL,
- FADE_EDGES_X,
- FILLED,
- H,
- LAYER,
-} from "./labTokens";
+import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
@@ -57,7 +49,7 @@ type SchemeDef = { name: string; dark: Pal; light: Pal };
const STATUS: Record> = {
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
- light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
+ light: { danger: "#b91c1c", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
};
const pal = (
@@ -70,7 +62,8 @@ const pal = (
accentFg: string,
): Pal => ({ bg, surface, border, fg, muted, accent, accentFg });
-// Backgrounds and text are today's (index.css); only the accents are new.
+// A literal copy of index.css's scheme tokens, so the four tiles render side by side (the live CSS
+// switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it.
const SCHEMES: SchemeDef[] = [
{
name: "Midnight · blue",
@@ -458,10 +451,7 @@ function Fades() {
-
+
{CHIPS.map((c) => (
diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts
index 04db6fe4..6f22f927 100644
--- a/frontend/src/design-lab/labTokens.ts
+++ b/frontend/src/design-lab/labTokens.ts
@@ -1,5 +1,6 @@
-// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). They are
-// the lab's stand-ins until S3 moves each into index.css / the ui/ primitives; then these go.
+// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). The colour
+// rules are real now (E28 S3a): each constant below names the index.css utility or token that holds
+// it, so the lab renders what the app ships. The height ladder moves into ui/Button in S3b.
/** B1 compact ladder: explicit heights, so one toolbar row lines up at 28. */
export const H = {
@@ -12,25 +13,15 @@ export const H = {
bar: "h-7",
} as const;
-/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode. */
-export const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
+/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */
+export const STRONG = "var(--edge-strong)";
-/** B3 state layers: the text colour at 10% (hover) / 16% (pressed) over whatever is underneath;
- * a filled control mixes its own text colour into the fill at 14% / 24%. */
-export const LAYER =
- "hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]";
-export const FILLED =
- "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]";
-export const DANGER =
- "bg-danger text-danger-fg hover:bg-[color-mix(in_srgb,var(--danger-fg)_14%,var(--danger))] active:bg-[color-mix(in_srgb,var(--danger-fg)_24%,var(--danger))]";
+/** B3 state layers (index.css utilities). */
+export const LAYER = "state-layer";
+export const FILLED = "state-filled";
+export const DANGER = "state-danger";
-/** B9: the header + fade edge — a crisp band and a 1rem gradient of the colour it sits on. Literal
- * strings per colour: Tailwind only generates classes it can read whole. */
-export const FADE_BELOW =
- "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
-export const FADE_BELOW_PANEL =
- "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
-export const FADE_ABOVE_PANEL =
- "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
-export const FADE_EDGES_X =
- "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)";
+/** B9: the header + fade edge (index.css utilities; the host is positioned). */
+export const FADE_BELOW = "fade-below";
+export const FADE_BELOW_PANEL = "fade-below-panel";
+export const FADE_ABOVE_PANEL = "fade-above-panel";
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index 197099d5..dd1b4883 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -33,8 +33,6 @@
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints",
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
- "showTuner": "Show the glass tuner",
- "showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
"startup": "Startup",
"startOnHome": "Start on Home",
"startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
@@ -249,60 +247,5 @@
"clear": "Clear",
"empty": "No event matches the filter.",
"emptyAll": "No events yet."
- },
- "tuner": {
- "open": "Open the glass tuner",
- "tab": "◐ GLASS",
- "title": "◐ Glass Tuner",
- "lab": "lab",
- "collapse": "Collapse",
- "treatment": "Dark treatment",
- "tabs": {
- "glass": "Glass",
- "palette": "Palette"
- },
- "paletteHint": "Overrides the live scheme (inline on ). Reset clears these back to the scheme.",
- "export": "Export → bake in",
- "syncHint": "Load the values of the theme you're currently viewing (light or dark) into the sliders",
- "sync": "⟳ Sync to current theme",
- "copy": "Copy CSS",
- "copied": "Copied ✓",
- "reset": "Reset",
- "slider": {
- "blur": "Blur radius",
- "saturate": "Saturation",
- "darkBright": "Dark brightness",
- "surfaceAlpha": "Panel opacity",
- "cardAlpha": "Card opacity",
- "menuAlpha": "Menu opacity",
- "borderAlpha": "Border opacity",
- "edge": "Edge-light rim",
- "inset": "Top highlight",
- "scrim": "Under-text scrim",
- "ambient": "Ambient intensity"
- },
- "color": {
- "bg": "Background",
- "surface": "Surface",
- "card": "Card",
- "border": "Border",
- "fg": "Text",
- "muted": "Muted",
- "accent": "Accent"
- },
- "preset": {
- "current": {
- "name": "Glass/image",
- "hint": "The default (over image)"
- },
- "solid": {
- "name": "Solid (flat)",
- "hint": "As shown w/ image off"
- },
- "readable": {
- "name": "Max readable",
- "hint": "Solid + strong scrim"
- }
- }
}
}
diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json
index 3456df8d..1d27c829 100644
--- a/frontend/src/i18n/locales/hu/settings.json
+++ b/frontend/src/i18n/locales/hu/settings.json
@@ -33,8 +33,6 @@
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése",
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
- "showTuner": "Glass tuner megjelenítése",
- "showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.",
"startup": "Indulás",
"startOnHome": "Indítás a kezdőlapon",
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
@@ -249,60 +247,5 @@
"clear": "Ürítés",
"empty": "Nincs a szűrésnek megfelelő esemény.",
"emptyAll": "Még nincs esemény."
- },
- "tuner": {
- "open": "Glass tuner megnyitása",
- "tab": "◐ ÜVEG",
- "title": "◐ Glass tuner",
- "lab": "labor",
- "collapse": "Összecsukás",
- "treatment": "Sötét téma",
- "tabs": {
- "glass": "Üveg",
- "palette": "Paletta"
- },
- "paletteHint": "Felülírja az élő színsémát (inline a -en). A Visszaállítás a séma színeit adja vissza.",
- "export": "Exportálás → beégetés",
- "syncHint": "A most látott téma (világos vagy sötét) értékeinek betöltése a csúszkákba",
- "sync": "⟳ Igazítás a jelenlegi témához",
- "copy": "CSS másolása",
- "copied": "Másolva ✓",
- "reset": "Visszaállítás",
- "slider": {
- "blur": "Elmosás sugara",
- "saturate": "Telítettség",
- "darkBright": "Sötét fényerő",
- "surfaceAlpha": "Panel fedése",
- "cardAlpha": "Kártya fedése",
- "menuAlpha": "Menü fedése",
- "borderAlpha": "Keret fedése",
- "edge": "Élfény-perem",
- "inset": "Felső csúcsfény",
- "scrim": "Szöveg alatti sötétítés",
- "ambient": "Környezeti intenzitás"
- },
- "color": {
- "bg": "Háttér",
- "surface": "Felület",
- "card": "Kártya",
- "border": "Keret",
- "fg": "Szöveg",
- "muted": "Halvány",
- "accent": "Kiemelés"
- },
- "preset": {
- "current": {
- "name": "Üveg/kép",
- "hint": "Az alapérték (kép fölött)"
- },
- "solid": {
- "name": "Tömör (lapos)",
- "hint": "Ahogy kép nélkül látszik"
- },
- "readable": {
- "name": "Legolvashatóbb",
- "hint": "Tömör + erős sötétítés"
- }
- }
}
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 947f6e0b..a2d21f3a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -46,7 +46,6 @@
--z-index-overlay: 50;
--z-index-popover: 60;
--z-index-tooltip: 100;
- --z-index-tuner: 9999;
}
/* Extra-small text steps in rem, so these labels scale with --font-scale like every other
@@ -62,6 +61,90 @@
--text-3xs: 0.625rem; /* 10px @ base */
}
+/* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design
+ lab both read, so a decided value is never restated as an arbitrary class. ===== */
+
+/* B3: a transparent control's hover / pressed layer. */
+@utility state-layer {
+ &:hover {
+ background-color: var(--hover);
+ }
+ &:active {
+ background-color: var(--press);
+ }
+}
+/* B3: a filled control mixes its own text colour into its fill (14% hover, 24% pressed). */
+@utility state-filled {
+ background-color: var(--accent);
+ color: var(--accent-fg);
+ &:hover {
+ background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent));
+ }
+ &:active {
+ background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent));
+ }
+}
+@utility state-danger {
+ background-color: var(--danger);
+ color: var(--danger-fg);
+ &:hover {
+ background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger));
+ }
+ &:active {
+ background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger));
+ }
+}
+/* A4: a control's ≥ 3:1 edge. */
+@utility edge-strong {
+ border-color: var(--edge-strong);
+}
+
+/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
+ dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
+@utility fade-below {
+ &::after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ inset-inline: 0;
+ top: 100%;
+ height: 1rem;
+ background: linear-gradient(to bottom, var(--bg), transparent);
+ }
+}
+@utility fade-below-panel {
+ &::after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ inset-inline: 0;
+ top: 100%;
+ height: 1rem;
+ background: linear-gradient(to bottom, var(--surface), transparent);
+ }
+}
+@utility fade-above-panel {
+ &::before {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ inset-inline: 0;
+ bottom: 100%;
+ height: 1rem;
+ background: linear-gradient(to top, var(--surface), transparent);
+ }
+}
+/* B9: the clipped ends of an overflowing chip row. */
+@utility fade-edges-x {
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 1.75rem,
+ #000 calc(100% - 1.75rem),
+ transparent
+ );
+}
+
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -225,22 +308,6 @@ html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body {
),
url("/backdrops/slate.svg");
}
-html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/youtube.svg");
-}
-html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/starship.svg");
-}
html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body {
background-image:
linear-gradient(
@@ -273,22 +340,6 @@ html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body {
),
url("/backdrops/light/slate.svg");
}
-html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/light/youtube.svg");
-}
-html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/light/starship.svg");
-}
html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body {
background-image:
linear-gradient(
@@ -320,11 +371,17 @@ html {
jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */
font-size: round(calc(16px * var(--font-scale)), 1px);
background: var(--bg);
- /* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`),
- so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens
- on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every
- hoverable row (the .selectable recipe + DataTable rows). */
- --hover: color-mix(in srgb, var(--fg) 9%, transparent);
+ /* State layers (docs/DESIGN.md B3): hover and pressed are the text colour laid over whatever the
+ control sits on — the page, a card or glass — so they read in every scheme and theme (it lightens
+ on dark, darkens on light). Never an opacity dim or a bg-card swap (invisible on a light card).
+ Applied through the state-layer / state-filled / state-danger utilities below. */
+ --hover: color-mix(in srgb, var(--fg) 10%, transparent);
+ --press: color-mix(in srgb, var(--fg) 16%, transparent);
+ /* A control's edge (DESIGN.md A4): ≥ 3:1 against the page in every scheme and mode, where --border
+ (a decorative divider) is not. theme.contract.test.ts holds the ratio. */
+ --edge-strong: color-mix(in srgb, var(--fg) 55%, var(--bg));
+ /* The coarse-pointer target (DESIGN.md A3): one token, read by .touch-target and the primitives. */
+ --touch-target: 2.75rem;
}
body {
@@ -598,8 +655,8 @@ body {
would be the one control that ignores the user's text-size setting. */
@media (pointer: coarse) {
.touch-target {
- min-width: 2.75rem;
- min-height: 2.75rem;
+ min-width: var(--touch-target);
+ min-height: var(--touch-target);
}
}
@@ -858,7 +915,7 @@ html[data-scheme="midnight"][data-theme="light"] {
--accent-fg: #ffffff;
}
-/* Forest — dark slate with teal/green accent */
+/* Forest — dark green-slate with a cyan accent (E28 B2: teal read as success) */
html[data-scheme="forest"][data-theme="dark"] {
--bg: #0a1512;
--surface: #0f1f1a;
@@ -866,7 +923,7 @@ html[data-scheme="forest"][data-theme="dark"] {
--border: #1f3a30;
--fg: #e6f0ec;
--muted: #90b1a4;
- --accent: #2dd4bf;
+ --accent: #22d3ee;
--accent-fg: #04110d;
}
html[data-scheme="forest"][data-theme="light"] {
@@ -876,11 +933,11 @@ html[data-scheme="forest"][data-theme="light"] {
--border: #d6e7e0;
--fg: #102a22;
--muted: #4d6b60;
- --accent: #0d9488;
+ --accent: #0e7490;
--accent-fg: #ffffff;
}
-/* Slate — neutral grey with warm orange accent (muted in dark) */
+/* Slate — neutral grey with a fuchsia accent (E28 B2: orange read as warn) */
html[data-scheme="slate"][data-theme="dark"] {
--bg: #15171c;
--surface: #1b1e25;
@@ -888,8 +945,8 @@ html[data-scheme="slate"][data-theme="dark"] {
--border: #2c313b;
--fg: #e7e9ee;
--muted: #9aa1ad;
- --accent: #e8913c;
- --accent-fg: #1a1206;
+ --accent: #e879f9;
+ --accent-fg: #1a0620;
}
html[data-scheme="slate"][data-theme="light"] {
--bg: #f6f7f9;
@@ -898,55 +955,11 @@ html[data-scheme="slate"][data-theme="light"] {
--border: #e3e6eb;
--fg: #1c1f26;
--muted: #5d636e;
- --accent: #d97706;
+ --accent: #a21caf;
--accent-fg: #ffffff;
}
-/* YouTube — near-black with red accent */
-html[data-scheme="youtube"][data-theme="dark"] {
- --bg: #0f0f0f;
- --surface: #181818;
- --card: #1f1f1f;
- --border: #303030;
- --fg: #f1f1f1;
- --muted: #aaaaaa;
- --accent: #ff3b46;
- --accent-fg: #ffffff;
-}
-html[data-scheme="youtube"][data-theme="light"] {
- --bg: #ffffff;
- --surface: #f9f9f9;
- --card: #ffffff;
- --border: #e5e5e5;
- --fg: #0f0f0f;
- --muted: #606060;
- --accent: #ff0033;
- --accent-fg: #ffffff;
-}
-
-/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */
-html[data-scheme="starship"][data-theme="dark"] {
- --bg: #050b16;
- --surface: #0b1728;
- --card: #101f36;
- --border: #1f3557;
- --fg: #dfeaff;
- --muted: #8aa6cc;
- --accent: #38b6ff;
- --accent-fg: #04101f;
-}
-html[data-scheme="starship"][data-theme="light"] {
- --bg: #eef4fb;
- --surface: #ffffff;
- --card: #ffffff;
- --border: #d3e0f0;
- --fg: #0b1b30;
- --muted: #4a5f7d;
- --accent: #0b6bd6;
- --accent-fg: #ffffff;
-}
-
-/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */
+/* Matrix — near-black with a lime accent (E28 B2: the terminal green read as success) */
html[data-scheme="matrix"][data-theme="dark"] {
--bg: #030a06;
--surface: #08130d;
@@ -954,7 +967,7 @@ html[data-scheme="matrix"][data-theme="dark"] {
--border: #163d27;
--fg: #cfe8d6;
--muted: #6fae86;
- --accent: #46b87c;
+ --accent: #a3e635;
--accent-fg: #04170c;
}
html[data-scheme="matrix"][data-theme="light"] {
@@ -964,7 +977,7 @@ html[data-scheme="matrix"][data-theme="light"] {
--border: #cfe6d6;
--fg: #08130b;
--muted: #4c6b56;
- --accent: #12a150;
+ --accent: #4d7c0f;
--accent-fg: #ffffff;
}
@@ -986,7 +999,9 @@ html[data-theme="dark"] {
--info-fg: #08172e;
}
html[data-theme="light"] {
- --danger: #dc2626;
+ /* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted
+ light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */
+ --danger: #b91c1c;
--danger-fg: #ffffff;
--warn: #b45309;
--warn-fg: #ffffff;
diff --git a/frontend/src/design-lab/labColor.ts b/frontend/src/lib/colorMath.ts
similarity index 61%
rename from frontend/src/design-lab/labColor.ts
rename to frontend/src/lib/colorMath.ts
index fbc98764..c5d072f0 100644
--- a/frontend/src/design-lab/labColor.ts
+++ b/frontend/src/lib/colorMath.ts
@@ -1,7 +1,7 @@
-// ── Fixed-palette maths ──────────────────────────────────────────────────────────────────────────
-// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on
-// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far
-// apart, unlike HSL's.
+// WCAG contrast and OKLCH hue distance on literal hex colours. The design constitution's colour rules
+// (docs/DESIGN.md, Tokens) are measured with these: theme.contract.test.ts runs them over every scheme
+// in index.css, and the design lab's scheme tiles render the same numbers. OKLCH, because equal hue
+// angles there look equally far apart, unlike HSL's.
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
@@ -31,3 +31,13 @@ export function hueGap(a: string, b: string): number {
const d = Math.abs(hue(a) - hue(b)) % 360;
return Math.min(d, 360 - d);
}
+
+/** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the
+ * browser does in the srgb space. */
+export function mixHex(a: string, b: string, w: number): string {
+ const [x, y] = [rgb(a), rgb(b)];
+ return `#${x
+ .map((v, i) => Math.round((v * w + (y[i] ?? 0) * (1 - w)) * 255))
+ .map((n) => n.toString(16).padStart(2, "0"))
+ .join("")}`;
+}
diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts
new file mode 100644
index 00000000..0ef21cbf
--- /dev/null
+++ b/frontend/src/lib/theme.contract.test.ts
@@ -0,0 +1,76 @@
+import { existsSync, readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { hexContrast, hueGap, mixHex } from "./colorMath";
+import { SCHEMES } from "./theme";
+
+// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css:
+// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on
+// an accent fill [WCAG 1.4.3];
+// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11];
+// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never
+// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did).
+// The status colours are text too, so they are held to 4.5:1 on every scheme's page as well.
+// A new scheme, or a retuned colour, has to pass this before it ships.
+
+const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
+type Mode = "dark" | "light";
+const MODES: Mode[] = ["dark", "light"];
+
+/** The `--name: #hex;` declarations of every block whose selector is exactly `selector`, merged in
+ * source order (the same selector also carries non-colour blocks, e.g. `color-scheme`). */
+function block(selector: string): Record {
+ const out: Record = {};
+ for (let at = css.indexOf(`${selector} {`); at >= 0; at = css.indexOf(`${selector} {`, at + 1)) {
+ const body = css.slice(at, css.indexOf("}", at));
+ for (const m of body.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) out[m[1]!] = m[2]!;
+ }
+ expect(Object.keys(out), `no colour tokens for ${selector} in index.css`).not.toHaveLength(0);
+ return out;
+}
+
+const status = (mode: Mode) => block(`html[data-theme="${mode}"]`);
+const scheme = (id: string, mode: Mode) => block(`html[data-scheme="${id}"][data-theme="${mode}"]`);
+
+it("styles exactly the schemes the app offers", () => {
+ const inCss = new Set(
+ [...css.matchAll(/html\[data-scheme="(\w+)"\]\[data-theme="dark"\]/g)].map((m) => m[1]),
+ );
+ expect([...inCss].sort()).toEqual(SCHEMES.map((s) => s.id).sort());
+ // The edge token is defined the way this test measures it.
+ expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/);
+});
+
+describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
+ it.each(MODES)("%s: backdrop rule and file", (mode) => {
+ const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
+ expect(css).toContain(`url("/${file}")`);
+ expect(existsSync(new URL(`../../public/${file}`, import.meta.url)), file).toBe(true);
+ });
+
+ it.each(MODES)("%s: text and control contrast", (mode) => {
+ const t = scheme(id, mode);
+ const s = status(mode);
+ const bg = t.bg!;
+ const measured = {
+ fg: hexContrast(t.fg!, bg),
+ muted: hexContrast(t.muted!, bg),
+ accentText: hexContrast(t.accent!, bg),
+ onAccent: hexContrast(t["accent-fg"]!, t.accent!),
+ edge: hexContrast(mixHex(t.fg!, bg, 0.55), bg),
+ danger: hexContrast(s.danger!, bg),
+ warn: hexContrast(s.warn!, bg),
+ success: hexContrast(s.success!, bg),
+ };
+ const failing = Object.entries(measured).filter(([k, v]) => v < (k === "edge" ? 3 : 4.5));
+ expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
+ });
+
+ it.each(MODES)("%s: the accent never looks like a status", (mode) => {
+ const accent = scheme(id, mode).accent!;
+ const s = status(mode);
+ const close = (["danger", "warn", "success"] as const)
+ .map((k) => [k, hueGap(accent, s[k]!)] as const)
+ .filter(([, gap]) => gap < 30);
+ expect(close.map(([k, gap]) => `${k} ${gap.toFixed(0)}°`)).toEqual([]);
+ });
+});
diff --git a/frontend/src/lib/theme.test.ts b/frontend/src/lib/theme.test.ts
new file mode 100644
index 00000000..dfe647f8
--- /dev/null
+++ b/frontend/src/lib/theme.test.ts
@@ -0,0 +1,30 @@
+import { expect, it } from "vitest";
+import { DEFAULT_THEME, normalizeTheme } from "./theme";
+
+// YouTube and Starship were retired in E28 with no data migration (owner, 2026-09-24): an account or
+// a browser that still stores one must land on the default scheme, not on an html[data-scheme] that no
+// CSS block styles (an unstyled page), and the retired tuner flag must not ride along into the next save.
+it("maps a retired scheme to the default and keeps only the known keys", () => {
+ const t = normalizeTheme({
+ mode: "light",
+ scheme: "youtube",
+ fontScale: 1.1,
+ bgImage: false,
+ bgFade: 35,
+ ...{ showTuner: true },
+ });
+ expect(t).toEqual({
+ mode: "light",
+ scheme: "midnight",
+ fontScale: 1.1,
+ bgImage: false,
+ bgFade: 35,
+ });
+});
+
+it("keeps a live scheme and fills what is missing or malformed from the defaults", () => {
+ expect(normalizeTheme({ scheme: "matrix", mode: "sepia", fontScale: Number.NaN })).toEqual({
+ ...DEFAULT_THEME,
+ scheme: "matrix",
+ });
+});
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts
index dd00f66a..cca27b92 100644
--- a/frontend/src/lib/theme.ts
+++ b/frontend/src/lib/theme.ts
@@ -1,15 +1,16 @@
import { LS, readAccountMerged, writeAccount } from "./storage";
type Mode = "dark" | "light";
-export type Scheme = "midnight" | "forest" | "slate" | "youtube" | "starship" | "matrix";
+export type Scheme = "midnight" | "forest" | "slate" | "matrix";
+// The scheme set (docs/DESIGN.md, decision B2): each earns its place with its own hue family, and its
+// accent stays ≥ 30° of OKLCH hue away from danger / warn / success. theme.contract.test.ts measures
+// every scheme's tokens in index.css against that rule and WCAG contrast.
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
- { id: "forest", name: "Forest", swatch: "#2dd4bf" },
- { id: "slate", name: "Slate", swatch: "#e8913c" },
- { id: "youtube", name: "YouTube", swatch: "#ff3b46" },
- { id: "starship", name: "Starship", swatch: "#38b6ff" },
- { id: "matrix", name: "Matrix", swatch: "#46b87c" },
+ { id: "forest", name: "Forest", swatch: "#22d3ee" },
+ { id: "slate", name: "Slate", swatch: "#e879f9" },
+ { id: "matrix", name: "Matrix", swatch: "#a3e635" },
];
export interface ThemePrefs {
@@ -20,8 +21,6 @@ export interface ThemePrefs {
bgImage: boolean;
/** How strongly the background image is faded out, 0–100% (higher = fainter). */
bgFade: number;
- /** Show the floating glass/theme tuner (a secondary "geek toy"); off by default. */
- showTuner: boolean;
}
export const DEFAULT_THEME: ThemePrefs = {
@@ -30,19 +29,34 @@ export const DEFAULT_THEME: ThemePrefs = {
fontScale: 1.06,
bgImage: true,
bgFade: 60,
- showTuner: false,
};
+/** A stored theme (this browser's copy or the account's server prefs) as the app can apply it: only
+ * the known keys, and a scheme or mode that no longer exists (YouTube and Starship were retired in
+ * E28) falls back to the default instead of painting an unstyled page. */
+export function normalizeTheme(raw: Partial>): ThemePrefs {
+ const d = DEFAULT_THEME;
+ const num = (v: unknown, fallback: number) =>
+ typeof v === "number" && Number.isFinite(v) ? v : fallback;
+ return {
+ mode: raw.mode === "light" || raw.mode === "dark" ? raw.mode : d.mode,
+ scheme: SCHEMES.some((s) => s.id === raw.scheme) ? (raw.scheme as Scheme) : d.scheme,
+ fontScale: num(raw.fontScale, d.fontScale),
+ bgImage: typeof raw.bgImage === "boolean" ? raw.bgImage : d.bgImage,
+ bgFade: num(raw.bgFade, d.bgFade),
+ };
+}
+
export function applyTheme(t: ThemePrefs): void {
const el = document.documentElement;
el.dataset.theme = t.mode;
el.dataset.scheme = t.scheme;
el.style.setProperty("--font-scale", String(t.fontScale));
- if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`);
+ el.style.setProperty("--bg-fade", `${t.bgFade}%`);
}
export function loadLocalTheme(): ThemePrefs {
- return readAccountMerged(LS.theme, DEFAULT_THEME);
+ return normalizeTheme(readAccountMerged(LS.theme, DEFAULT_THEME));
}
export function saveLocalTheme(t: ThemePrefs): void {