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:
@@ -0,0 +1,43 @@
|
||||
// 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);
|
||||
|
||||
export function hexContrast(a: string, b: string): number {
|
||||
const l = (h: string) => {
|
||||
const [r = 0, g = 0, bl = 0] = rgb(h).map(lin2);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
|
||||
};
|
||||
const x = l(a);
|
||||
const y = l(b);
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||||
}
|
||||
|
||||
function hue(hex: string): number {
|
||||
const [r = 0, g = 0, b = 0] = rgb(hex).map(lin2);
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
||||
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
||||
return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
/** The OKLCH hue angle between two colours, 0..180°. */
|
||||
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("")}`;
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
+25
-11
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user