diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 90ee3714..17c48a41 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -77,10 +77,22 @@ style guide. `--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3] +- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css` + against the two rules above (text 4.5:1, a control edge 3:1, the status colours as text, the accent's hue + gap). A scheme or a retuned colour that breaks them does not pass the gate. +- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was + retired (E28), because free tuning is exactly the drift these rules exist to stop. +- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a + grey `overlay` grain that breaks the gradient banding (measured by pixel-scanning a canvas, never a + screenshot, which rescales). - **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%** (hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. +- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`, + `--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`, + `edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab + rendering uses them; restating one as an arbitrary class is a violation. - **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a px font size or a px-frozen box is an eslint error. - **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and @@ -289,3 +301,4 @@ Wherever there is text, the user can select it and copy it, and no click action | 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap | | 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | | 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip | +| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) | diff --git a/frontend/public/backdrops/forest.svg b/frontend/public/backdrops/forest.svg index 5e8f0f44..fc664188 100644 --- a/frontend/public/backdrops/forest.svg +++ b/frontend/public/backdrops/forest.svg @@ -1,11 +1,11 @@ - - + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/forest.svg b/frontend/public/backdrops/light/forest.svg index 9ecade12..205a6cbe 100644 --- a/frontend/public/backdrops/light/forest.svg +++ b/frontend/public/backdrops/light/forest.svg @@ -1,11 +1,11 @@ - - + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/matrix.svg b/frontend/public/backdrops/light/matrix.svg index b679c1b9..9ef8303c 100644 --- a/frontend/public/backdrops/light/matrix.svg +++ b/frontend/public/backdrops/light/matrix.svg @@ -1,11 +1,11 @@ - - - + + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/midnight.svg b/frontend/public/backdrops/light/midnight.svg index 37dd1b7b..15cf1c8a 100644 --- a/frontend/public/backdrops/light/midnight.svg +++ b/frontend/public/backdrops/light/midnight.svg @@ -5,7 +5,7 @@ - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/slate.svg b/frontend/public/backdrops/light/slate.svg index 83354ab3..c8027b7d 100644 --- a/frontend/public/backdrops/light/slate.svg +++ b/frontend/public/backdrops/light/slate.svg @@ -1,18 +1,18 @@ - - - - + + + + - + - + - + diff --git a/frontend/public/backdrops/light/starship.svg b/frontend/public/backdrops/light/starship.svg deleted file mode 100644 index 9ba4aeb9..00000000 --- a/frontend/public/backdrops/light/starship.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/backdrops/light/youtube.svg b/frontend/public/backdrops/light/youtube.svg deleted file mode 100644 index 16cb1ffa..00000000 --- a/frontend/public/backdrops/light/youtube.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg index 3e699a7b..038ecfc8 100644 --- a/frontend/public/backdrops/matrix.svg +++ b/frontend/public/backdrops/matrix.svg @@ -1,11 +1,11 @@ - - - + + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/midnight.svg b/frontend/public/backdrops/midnight.svg index 217531ff..f51b6564 100644 --- a/frontend/public/backdrops/midnight.svg +++ b/frontend/public/backdrops/midnight.svg @@ -5,7 +5,7 @@ - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/slate.svg b/frontend/public/backdrops/slate.svg index b5c4889c..bb77777f 100644 --- a/frontend/public/backdrops/slate.svg +++ b/frontend/public/backdrops/slate.svg @@ -1,18 +1,18 @@ - - - - + + + + - + - + - + diff --git a/frontend/public/backdrops/starship.svg b/frontend/public/backdrops/starship.svg deleted file mode 100644 index 2a8d704d..00000000 --- a/frontend/public/backdrops/starship.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/backdrops/youtube.svg b/frontend/public/backdrops/youtube.svg deleted file mode 100644 index 6d50059a..00000000 --- a/frontend/public/backdrops/youtube.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38104105..6a09d077 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,7 +32,6 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import BackToTop from "./components/ui/BackToTop"; import PageShell from "./components/ui/PageShell"; -import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import DebugRecorder from "./components/DebugRecorder"; import Toaster from "./components/ui/Toaster"; @@ -475,8 +474,6 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} - {/* Opt-in live appearance tuner (Settings → Appearance; off by default). */} - ); } diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx deleted file mode 100644 index f1c3a679..00000000 --- a/frontend/src/components/GlassTuner.tsx +++ /dev/null @@ -1,465 +0,0 @@ -import { useEffect, useId, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import Overlay from "./ui/Overlay"; -import { copyText } from "../lib/clipboard"; -import { usePrefs } from "./prefsContext"; - -/** - * A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it - * writes the glass CSS custom properties (index.css `:root`) inline on the document element for - * instant, build-free feedback, and persists to localStorage. - * - * Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy" - * for dialling in the surface values. "Copy CSS" exports the values. - */ -const LS_KEY = "siftlode.devGlassTuner"; - -type Slider = { - k: string; - /** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */ - id: string; - min: number; - max: number; - step: number; - unit: string; - def: number; -}; - -// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it. -// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css. -const SLIDERS: Slider[] = [ - { k: "--glass-blur", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 }, - { k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 }, - { - k: "--glass-dark-bright", - id: "darkBright", - min: 1, - max: 2, - step: 0.05, - unit: "", - def: 1.2, - }, - { - k: "--glass-surface-alpha", - id: "surfaceAlpha", - min: 40, - max: 100, - step: 1, - unit: "%", - def: 50, - }, - { - k: "--glass-card-alpha", - id: "cardAlpha", - min: 40, - max: 100, - step: 1, - unit: "%", - def: 60, - }, - { - k: "--glass-menu-alpha", - id: "menuAlpha", - min: 40, - max: 100, - step: 1, - unit: "%", - def: 66, - }, - { - k: "--glass-border-alpha", - id: "borderAlpha", - min: 20, - max: 100, - step: 1, - unit: "%", - def: 80, - }, - { k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 }, - { k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 }, - { k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 }, - { k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 }, -]; - -const PALETTE: { k: string; id: string }[] = [ - { k: "--bg", id: "bg" }, - { k: "--surface", id: "surface" }, - { k: "--card", id: "card" }, - { k: "--border", id: "border" }, - { k: "--fg", id: "fg" }, - { k: "--muted", id: "muted" }, - { k: "--accent", id: "accent" }, -]; - -// Each preset resets every slider to its default, then applies these overrides. -// Named under `settings.tuner.preset.`. -const PRESETS: { id: string; over: Record }[] = [ - { id: "current", over: {} }, - { - id: "solid", - over: { - "--glass-blur": 20, - "--glass-dark-bright": 1.25, - "--glass-surface-alpha": 94, - "--glass-card-alpha": 94, - "--glass-menu-alpha": 98, - "--glass-edge": 10, - "--glass-inset": 16, - "--glass-scrim": 14, - }, - }, - { - id: "readable", - over: { - "--glass-edge": 42, - "--glass-scrim": 45, - "--glass-surface-alpha": 96, - "--glass-card-alpha": 96, - }, - }, -]; - -const ALL = SLIDERS; -const root = () => document.documentElement; -const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); - -type Saved = { - vars?: Record; - palette?: Record; - open?: boolean; -}; - -function loadSaved(): Saved { - try { - return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved; - } catch { - return {}; - } -} - -export default function GlassTuner() { - // Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider. - const enabled = usePrefs().theme.showTuner; - const { t } = useTranslation(); - // Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list: - // a memo is a caching hint React may discard and recompute, while "the saved values as they were - // when this mounted" is state — and the effect below applies them exactly once. - const [initial] = useState(loadSaved); - const [vars, setVars] = useState>(() => { - const base: Record = {}; - ALL.forEach((s) => (base[s.k] = s.def)); - return { ...base, ...(initial.vars || {}) }; - }); - const [palette, setPalette] = useState>(() => initial.palette || {}); - const [open, setOpen] = useState(() => initial.open ?? true); - const [tab, setTab] = useState<"glass" | "palette">("glass"); - const [copied, setCopied] = useState(false); - const [themeTick, setThemeTick] = useState(0); - // Seed the colour pickers from the live scheme for display (without forcing them onto ). - const [swatches, setSwatches] = useState>({}); - - // Apply saved var overrides (and any saved palette edits) once on mount. - useEffect(() => { - Object.entries(initial.vars || {}).forEach(([k, v]) => { - const unit = ALL.find((s) => s.k === k)?.unit ?? ""; - root().style.setProperty(k, v + unit); - }); - Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v)); - const sw: Record = {}; - PALETTE.forEach((p) => { - const v = (initial.palette?.[p.k] || readVar(p.k)).trim(); - sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000"; - }); - // eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit. - setSwatches(sw); - }, [initial]); - - function persist(next: Partial) { - const cur = loadSaved(); - localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next })); - } - - function setVar(k: string, value: number) { - const unit = ALL.find((s) => s.k === k)?.unit ?? ""; - root().style.setProperty(k, value + unit); - setVars((prev) => { - const next = { ...prev, [k]: value }; - persist({ vars: next }); - return next; - }); - } - - function setColor(k: string, hex: string) { - root().style.setProperty(k, hex); - setSwatches((s) => ({ ...s, [k]: hex })); - setPalette((prev) => { - const next = { ...prev, [k]: hex }; - persist({ palette: next }); - return next; - }); - } - - function applyPreset(over: Record) { - const next: Record = { ...vars }; - SLIDERS.forEach((s) => { - const v = over[s.k] ?? s.def; - next[s.k] = v; - root().style.setProperty(s.k, v + s.unit); - }); - setVars(next); - persist({ vars: next }); - } - - function reset() { - [...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k)); - const base: Record = {}; - ALL.forEach((s) => (base[s.k] = s.def)); - setVars(base); - setPalette({}); - localStorage.removeItem(LS_KEY); - const sw: Record = {}; - PALETTE.forEach((p) => { - const v = readVar(p.k); - sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000"; - }); - setSwatches(sw); - } - - // Re-read the LIVE, theme-driven glass values into the sliders (clearing inline overrides first), - // so the panel reflects whatever theme is active — switch to light, hit this, and the opacity - // sliders show the light-theme values (index.css `html[data-theme="light"]`) ready to tune. - function syncToTheme() { - ALL.forEach((s) => root().style.removeProperty(s.k)); - const next: Record = { ...vars }; - ALL.forEach((s) => { - const n = parseFloat(readVar(s.k)); - if (!Number.isNaN(n)) next[s.k] = n; - }); - setVars(next); - persist({ vars: {} }); - setThemeTick((t) => t + 1); - } - - // Export the values that differ from the baseline, headered for the active theme's scope so a - // light-theme tune pastes into the right block. - const cssOut = useMemo(() => { - void themeTick; // re-run when the active theme is re-synced - const lines: string[] = []; - ALL.forEach((s) => { - if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`); - }); - PALETTE.forEach((p) => { - if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`); - }); - const scope = root().dataset.theme === "light" ? 'html[data-theme="light"]' : ":root"; - return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */"; - }, [vars, palette, themeTick]); - - async function copy() { - if (await copyText(cssOut)) { - setCopied(true); - setTimeout(() => setCopied(false), 1200); - } - } - - function persistOpen(v: boolean) { - setOpen(v); - persist({ open: v }); - } - - if (!enabled) return null; - - return ( - -
- {!open && ( - - )} - {open && ( -
-
- {t("settings.tuner.title")} - - {t("settings.tuner.lab")} - - -
- -
- {/* Treatment presets */} -
-
- {t("settings.tuner.treatment")} -
-
- {PRESETS.map((p) => ( - - ))} -
-
- - {/* Tab switch */} -
- {(["glass", "palette"] as const).map((id) => ( - - ))} -
- - {tab === "glass" && ( -
- {SLIDERS.map((s) => ( - setVar(s.k, v)} - /> - ))} -
- )} - - {tab === "palette" && ( -
-

- {t("settings.tuner.paletteHint")} -

- {PALETTE.map((p) => ( - - ))} -
- )} - - {/* Export */} -
-
- {t("settings.tuner.export")} -
- -
- - -
-
-                  {cssOut}
-                
-
-
-
- )} -
-
- ); -} - -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 ( -
- - onChange(Number(e.target.value))} - className="w-16 text-right text-2xs tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent" - /> - onChange(Number(e.target.value))} - aria-label={label} - className="col-span-2 w-full accent-accent" - /> -
- ); -} 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 {