feat(design): the scheme set, the colour contract and the token utilities (E28 S3a)

Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube
and Starship retired with no data migration: normalizeTheme maps a stored
retired scheme to Midnight. A vitest contract measures every scheme x mode
(text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap);
it caught light-mode danger at 4.43:1, now red-700. The state layers, the
strong edge, the touch target and the fades are index.css tokens and
utilities the lab now reads. Backdrops recoloured to the new accents, with
a grey overlay grain that actually breaks the banding (longest flat run
15-26px -> 4-8px). The glass tuner is retired.
This commit is contained in:
2026-09-24 04:33:13 +02:00
parent 9c4a999e54
commit ad136c3fe8
26 changed files with 326 additions and 851 deletions
+25 -11
View File
@@ -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<Record<keyof ThemePrefs, unknown>>): 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 {