From f5f302ebb8fd430dde070b523741937a0ea1c56a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 07:23:21 +0100 Subject: [PATCH 1/6] fix(webapp): hold wcag aa across the fourteen theme presets Measure every preset's contrast from the tokens the stylesheets resolve, with an oklch-to-srgb conversion held to published reference values, and fix the two pairs that fell short: the light material's muted ink on the tinted deepest tone (Midnight, Lavender, Crimson light) and Sunset light's accent as text on its ground. Signed-off-by: InstaZDLL --- .../src/styles/design-tokens/contrast.test.ts | 124 ++++++++ webapp/src/styles/design-tokens/contrast.ts | 194 ++++++++++++ .../design-tokens/themes-contrast.test.ts | 276 ++++++++++++++++++ webapp/src/styles/fluid.css | 13 +- 4 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 webapp/src/styles/design-tokens/contrast.test.ts create mode 100644 webapp/src/styles/design-tokens/contrast.ts create mode 100644 webapp/src/styles/design-tokens/themes-contrast.test.ts diff --git a/webapp/src/styles/design-tokens/contrast.test.ts b/webapp/src/styles/design-tokens/contrast.test.ts new file mode 100644 index 00000000..5654d6b9 --- /dev/null +++ b/webapp/src/styles/design-tokens/contrast.test.ts @@ -0,0 +1,124 @@ +// The colour maths the per-preset contrast test stands on, held to values +// published elsewhere: a conversion that drifted would move every ratio the +// other test reads, and a ratio is only as good as the colour under it. + +import { describe, expect, it } from "vitest"; + +import { + composite, + contrastRatio, + mixSrgb, + oklchToSrgb, + parseColor, + toHex, +} from "./contrast"; + +describe("oklchToSrgb", () => { + it("lands the sRGB primaries on their published OKLCH coordinates", () => { + // The primaries in OKLCH, as CSS Color 4's sample code and oklch.com + // give them. + const cases: Array<[number, number, number, [number, number, number]]> = [ + [0.627955, 0.257683, 29.2339, [1, 0, 0]], + [0.86644, 0.294827, 142.4953, [0, 1, 0]], + [0.452014, 0.313214, 264.052, [0, 0, 1]], + [1, 0, 0, [1, 1, 1]], + [0, 0, 0, [0, 0, 0]], + ]; + for (const [l, c, h, expected] of cases) { + const actual = oklchToSrgb(l, c, h); + for (let index = 0; index < 3; index += 1) { + expect(actual[index], `oklch(${l} ${c} ${h})[${index}]`).toBeCloseTo( + expected[index] as number, + 3, + ); + } + } + }); + + it("matches the hex Tailwind v4 publishes for its in-gamut stops", () => { + // Tailwind's own sRGB fallbacks for the stops the light material spends. + expect(toHex(parseColor("oklch(0.508 0.118 165.612)"))).toBe("#007a55"); + expect(toHex(parseColor("oklch(0.5 0.134 242.749)"))).toBe("#0069a8"); + expect(toHex(parseColor("oklch(0.457 0.24 277.023)"))).toBe("#432dd7"); + }); + + it("keeps an out-of-gamut colour whole until the end", () => { + // Emerald 500 is a Display P3 colour: its red channel is below 0 in + // sRGB, and only the paint clips it. + const [r] = oklchToSrgb(0.696, 0.17, 162.48); + expect(r).toBeLessThan(-0.2); + }); +}); + +describe("parseColor", () => { + it("reads the spellings the stylesheets use", () => { + expect(toHex(parseColor("#abc"))).toBe("#aabbcc"); + expect(toHex(parseColor("#1b1a17"))).toBe("#1b1a17"); + expect(parseColor("rgb(27 26 23 / 9%)").alpha).toBeCloseTo(0.09, 6); + expect(parseColor("rgb(255 255 255 / 0.5)").alpha).toBe(0.5); + expect(parseColor("transparent").alpha).toBe(0); + expect(toHex(parseColor("white"))).toBe("#ffffff"); + }); + + it("refuses what it cannot read rather than guessing", () => { + expect(() => parseColor("hsl(0 0% 50%)")).toThrow(/unsupported/); + }); +}); + +describe("mixSrgb", () => { + it("mixes in gamma-encoded sRGB, as color-mix(in srgb) does", () => { + // Halfway between black and white in sRGB is #808080, not the + // perceptual or the linear-light middle. + const half = mixSrgb(parseColor("white"), 50, parseColor("black"), null); + expect(toHex(half)).toBe("#808080"); + }); + + it("turns a mix with transparent into translucency, not darkness", () => { + const soft = mixSrgb( + parseColor("#ff0000"), + 10, + parseColor("transparent"), + null, + ); + expect(soft.alpha).toBeCloseTo(0.1, 6); + expect(toHex(soft)).toBe("#ff0000"); + }); + + it("lowers the alpha when the percentages sum below 100", () => { + const thin = mixSrgb(parseColor("#000"), 20, parseColor("#fff"), 20); + expect(thin.alpha).toBeCloseTo(0.4, 6); + expect(toHex(thin)).toBe("#808080"); + }); +}); + +describe("contrastRatio", () => { + it("gives WCAG's own reference ratios", () => { + expect(contrastRatio(parseColor("#fff"), parseColor("#000"))).toBeCloseTo( + 21, + 5, + ); + expect(contrastRatio(parseColor("#000"), parseColor("#000"))).toBe(1); + // #767676 is the lightest grey that holds 4.5:1 on white; #777 is not. + expect( + contrastRatio(parseColor("#767676"), parseColor("#fff")), + ).toBeCloseTo(4.54, 2); + expect(contrastRatio(parseColor("#777"), parseColor("#fff"))).toBeLessThan( + 4.5, + ); + }); + + it("paints a translucent foreground over its ground first", () => { + const veil = parseColor("rgb(0 0 0 / 50%)"); + const white = parseColor("#fff"); + expect(contrastRatio(veil, white)).toBeCloseTo( + contrastRatio(composite(veil, white), white), + 10, + ); + }); + + it("refuses a translucent ground, which has no colour of its own", () => { + expect(() => + contrastRatio(parseColor("#fff"), parseColor("rgb(0 0 0 / 50%)")), + ).toThrow(/opaque/); + }); +}); diff --git a/webapp/src/styles/design-tokens/contrast.ts b/webapp/src/styles/design-tokens/contrast.ts new file mode 100644 index 00000000..4152a63f --- /dev/null +++ b/webapp/src/styles/design-tokens/contrast.ts @@ -0,0 +1,194 @@ +// WCAG contrast, measured from the tokens rather than from a screenshot. +// +// The presets write their accents as OKLCH and the stylesheets build every +// surface with `color-mix(in srgb, …)`, so asking "does this text hold AA on +// that ground" for fourteen presets needs the arithmetic the browser does: +// OKLCH to sRGB, the mix, alpha over a ground, then the WCAG 2 luminance. No +// dependency does that for us without pulling a colour library into a client +// that has none, and the whole of it is a page of maths. +// +// **Unclamped until the end.** The Tailwind v4 scales the accents come from +// sit partly outside sRGB (they were chosen in Display P3). A browser keeps an +// out-of-gamut value whole through `color-mix` and clips it only when it +// paints to an sRGB screen, so the channels stay unclamped here until the +// luminance, which clips each one the way that paint does. + +/** A colour as gamma-encoded sRGB channels (nominally 0–1) and an alpha. */ +export type Rgba = { r: number; g: number; b: number; alpha: number }; + +const TRANSPARENT: Rgba = { r: 0, g: 0, b: 0, alpha: 0 }; + +function linearToGamma(channel: number): number { + const sign = channel < 0 ? -1 : 1; + const magnitude = Math.abs(channel); + return magnitude <= 0.0031308 + ? channel * 12.92 + : sign * (1.055 * magnitude ** (1 / 2.4) - 0.055); +} + +function gammaToLinear(channel: number): number { + return channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; +} + +/** + * OKLCH to gamma-encoded sRGB, through OKLab and linear sRGB with Björn + * Ottosson's published matrices (the ones CSS Color 4 specifies). The result + * is not clamped: see the note at the head of this file. + */ +export function oklchToSrgb( + lightness: number, + chroma: number, + hueDegrees: number, +): [number, number, number] { + const hue = (hueDegrees * Math.PI) / 180; + const a = chroma * Math.cos(hue); + const b = chroma * Math.sin(hue); + const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + linearToGamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + linearToGamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + linearToGamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ]; +} + +function parseAlpha(text: string | undefined): number { + if (text === undefined) return 1; + const value = text.trim(); + return value.endsWith("%") ? Number.parseFloat(value) / 100 : Number(value); +} + +/** + * One colour literal as the stylesheets write them: `#rgb`, `#rrggbb`, + * `rgb(r g b / a)`, `oklch(l c h)`, `white`, `black` or `transparent`. + * Anything else throws — a measurement that silently read an unknown colour + * as black would pass for the wrong reason. + */ +export function parseColor(text: string): Rgba { + const value = text.trim().toLowerCase(); + if (value === "transparent") return TRANSPARENT; + if (value === "white") return { r: 1, g: 1, b: 1, alpha: 1 }; + if (value === "black") return { r: 0, g: 0, b: 0, alpha: 1 }; + const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/); + if (hex?.[1]) { + const digits = + hex[1].length === 3 + ? [...hex[1]].map((digit) => digit + digit).join("") + : hex[1]; + const channel = (index: number) => + Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16) / 255; + return { r: channel(0), g: channel(1), b: channel(2), alpha: 1 }; + } + const rgb = value.match( + /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[/,]\s*([\d.]+%?))?\s*\)$/, + ); + if (rgb) { + return { + r: Number(rgb[1]) / 255, + g: Number(rgb[2]) / 255, + b: Number(rgb[3]) / 255, + alpha: parseAlpha(rgb[4]), + }; + } + const oklch = value.match( + /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+%?))?\s*\)$/, + ); + if (oklch) { + const lightness = Number(oklch[1]) / (oklch[2] === "%" ? 100 : 1); + const [r, g, b] = oklchToSrgb( + lightness, + Number(oklch[3]), + Number(oklch[4]), + ); + return { r, g, b, alpha: parseAlpha(oklch[5]) }; + } + throw new Error(`contrast: unsupported colour "${text}"`); +} + +/** + * `color-mix(in srgb, first p1, second p2)` as CSS Color 5 defines it: a + * missing percentage is the other's complement, both are scaled to a sum of + * 100, a sum below 100 lowers the alpha by as much, and the channels are + * interpolated premultiplied by their alpha — which is why mixing a colour + * with `transparent` makes it translucent rather than darker. + */ +export function mixSrgb( + first: Rgba, + firstPercent: number | null, + second: Rgba, + secondPercent: number | null, +): Rgba { + let p1 = firstPercent; + let p2 = secondPercent; + if (p1 === null && p2 === null) { + p1 = 50; + p2 = 50; + } else if (p1 === null) { + p1 = 100 - (p2 as number); + } else if (p2 === null) { + p2 = 100 - p1; + } + const sum = (p1 as number) + (p2 as number); + const w1 = (p1 as number) / sum; + const w2 = (p2 as number) / sum; + const alpha = first.alpha * w1 + second.alpha * w2; + const multiplier = Math.min(sum, 100) / 100; + if (alpha === 0) return { ...TRANSPARENT }; + const channel = (key: "r" | "g" | "b") => + (first[key] * first.alpha * w1 + second[key] * second.alpha * w2) / alpha; + return { + r: channel("r"), + g: channel("g"), + b: channel("b"), + alpha: alpha * multiplier, + }; +} + +/** `top` painted over `bottom`, which is taken as opaque. */ +export function composite(top: Rgba, bottom: Rgba): Rgba { + const a = top.alpha; + return { + r: top.r * a + bottom.r * (1 - a), + g: top.g * a + bottom.g * (1 - a), + b: top.b * a + bottom.b * (1 - a), + alpha: 1, + }; +} + +/** WCAG 2 relative luminance, each channel clipped to what a screen shows. */ +export function relativeLuminance(colour: Rgba): number { + const clip = (channel: number) => Math.min(1, Math.max(0, channel)); + return ( + 0.2126 * gammaToLinear(clip(colour.r)) + + 0.7152 * gammaToLinear(clip(colour.g)) + + 0.0722 * gammaToLinear(clip(colour.b)) + ); +} + +/** + * The WCAG contrast ratio of `foreground` on `background`. A translucent + * foreground is first painted over the background, as it would be on screen; + * the background must already be opaque (composite it over its own ground). + */ +export function contrastRatio(foreground: Rgba, background: Rgba): number { + if (background.alpha < 1) { + throw new Error("contrast: the background must be opaque"); + } + const ink = + foreground.alpha < 1 ? composite(foreground, background) : foreground; + const a = relativeLuminance(ink); + const b = relativeLuminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +/** A colour as `#rrggbb`, clipped — for messages and reference tests. */ +export function toHex(colour: Rgba): string { + const byte = (channel: number) => + Math.round(Math.min(1, Math.max(0, channel)) * 255) + .toString(16) + .padStart(2, "0"); + return `#${byte(colour.r)}${byte(colour.g)}${byte(colour.b)}`; +} diff --git a/webapp/src/styles/design-tokens/themes-contrast.test.ts b/webapp/src/styles/design-tokens/themes-contrast.test.ts new file mode 100644 index 00000000..d96ae2a0 --- /dev/null +++ b/webapp/src/styles/design-tokens/themes-contrast.test.ts @@ -0,0 +1,276 @@ +import postcss from "postcss"; +import { describe, expect, it } from "vitest"; + +import fluid from "../fluid.css?raw"; +import refresh from "../refresh.css?raw"; +import styles from "../styles.css?raw"; +import { applyTheme } from "./apply"; +import { + composite, + contrastRatio, + mixSrgb, + parseColor, + type Rgba, + toHex, +} from "./contrast"; +import { THEME_PRESETS, type ThemePreset } from "./themes"; + +/** The sheets in the order `main.tsx` imports them: later wins a tie. */ +const SHEETS = [styles, refresh, fluid]; + +type Declaration = { + name: string; + value: string; + weight: number; + order: number; +}; + +/** + * Every custom property a top-level `:root…` rule declares, with the state + * the rule asks of the root: `.dark`, `:not(.dark)`, `[data-theme="…"]`. + * Only those rules define tokens; a rule with a descendant (`:root .a`) or + * one inside `@media` is not a token block and is skipped. + */ +const ROOT_SELECTOR = + /^:root((?:\.dark|:not\(\.dark\)|\[data-theme="[^"]+"\])*)$/; + +function tokenDeclarations(): Array< + Declaration & { dark: boolean | null; theme: string | null } +> { + const found: Array< + Declaration & { dark: boolean | null; theme: string | null } + > = []; + let order = 0; + for (const sheet of SHEETS) { + const root = postcss.parse(sheet); + root.each((node) => { + if (node.type !== "rule") return; + for (const selector of node.selectors) { + const match = selector.trim().match(ROOT_SELECTOR); + if (!match) continue; + const qualifiers = match[1] ?? ""; + const dark = qualifiers.includes(":not(.dark)") + ? false + : qualifiers.includes(".dark") + ? true + : null; + const theme = qualifiers.match(/\[data-theme="([^"]+)"\]/)?.[1] ?? null; + const weight = + 1 + (qualifiers.match(/\.dark|\[data-theme/g)?.length ?? 0); + node.walkDecls((decl) => { + if (!decl.prop.startsWith("--")) return; + order += 1; + found.push({ + name: decl.prop, + value: decl.value, + weight, + order, + dark, + theme, + }); + }); + } + }); + } + return found; +} + +/** + * The custom properties in force on the root for one preset: the sheets' + * token blocks that match its mode and id, by specificity then source order, + * and over them the inline values `applyTheme` itself writes — read back from + * the element, so the mapping from preset fields to variables is the real one + * and not a copy of it. + */ +function tokensFor(preset: ThemePreset): Map { + const dark = preset.mode === "dark"; + const applicable = tokenDeclarations() + .filter( + (decl) => + (decl.dark === null || decl.dark === dark) && + (decl.theme === null || decl.theme === preset.id), + ) + .sort((a, b) => a.weight - b.weight || a.order - b.order); + const tokens = new Map(); + for (const decl of applicable) tokens.set(decl.name, decl.value); + const root = document.documentElement; + root.removeAttribute("style"); + applyTheme(preset); + for (let index = 0; index < root.style.length; index += 1) { + const name = root.style.item(index); + if (name.startsWith("--")) { + tokens.set(name, root.style.getPropertyValue(name).trim()); + } + } + root.removeAttribute("style"); + return tokens; +} + +/** Split on commas that are not inside parentheses. */ +function splitTopLevel(text: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (char === "(") depth += 1; + else if (char === ")") depth -= 1; + else if (char === "," && depth === 0) { + parts.push(text.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(text.slice(start).trim()); + return parts; +} + +/** The text between a function's opening parenthesis and its matching close. */ +function inside(text: string, name: string): string { + if (!text.startsWith(`${name}(`) || !text.endsWith(")")) { + throw new Error(`expected ${name}(…), got "${text}"`); + } + return text.slice(name.length + 1, -1); +} + +/** + * A colour value as the stylesheets write them: a literal, a `var()`, or a + * `color-mix(in srgb, …)` of either. Anything else throws, so a sheet that + * starts spelling a token some new way fails this test instead of being + * measured as something it is not. + */ +function evaluate( + value: string, + tokens: Map, + seen = new Set(), +): Rgba { + const text = value.replace(/\s+/g, " ").trim(); + if (text.startsWith("var(")) { + const [name, fallback] = splitTopLevel(inside(text, "var")); + if (!name) throw new Error(`empty var() in "${value}"`); + if (seen.has(name)) throw new Error(`cycle through ${name}`); + const next = tokens.get(name) ?? fallback; + if (next === undefined) throw new Error(`undefined token ${name}`); + return evaluate(next, tokens, new Set([...seen, name])); + } + if (text.startsWith("color-mix(")) { + const [space, first, second] = splitTopLevel(inside(text, "color-mix")); + if (space !== "in srgb" || !first || !second) { + throw new Error(`unsupported mix "${value}"`); + } + const operand = (part: string): [Rgba, number | null] => { + const percent = part.match(/^(.*\S)\s+([\d.]+)%$/); + return percent?.[1] + ? [evaluate(percent[1], tokens, seen), Number(percent[2])] + : [evaluate(part, tokens, seen), null]; + }; + const [a, pa] = operand(first); + const [b, pb] = operand(second); + return mixSrgb(a, pa, b, pb); + } + return parseColor(text); +} + +/** WCAG 1.4.3: text at the sizes the interface sets it. */ +const TEXT = 4.5; +/** WCAG 1.4.11: a mark that carries state — a focus ring, an edge, a border. */ +const INDICATOR = 3; + +type Measure = { pair: string; ratio: number; minimum: number }; + +/** + * Every pairing the material makes, measured for one preset. + * + * The grounds are the ones text actually sits on in the Fluid Editorial + * material (`fluid.css`, the last sheet, which redefines every colour token + * the earlier ones set): the page, a panel, a raised panel, the deepest of + * the translucent tones a hovered or open item lays over the page. + * + * Not the cover's light (`--glow`, the two pools `body::before` draws): a + * record's own colours replace the theme's stand-in as soon as one plays, and + * the pools fade out from centres that sit partly off screen, so a ratio at + * their nominal peak would measure a colour nobody sees. + * + * Then the pairs. The three inks hold AA on every ground — `fluid.css` + * promises it for the deepest tone in so many words. The accent is spent as + * text (links, kickers, a hovered title), so it is held to the text minimum, + * stricter than the 3:1 a mark alone would need; it is also the edge of the + * playing row and the focus ring, which sit beside a tone, held to 3:1. And + * the ink set on a filled accent — the primary button — holds AA on it. + */ +function measure(preset: ThemePreset): Measure[] { + const tokens = tokensFor(preset); + const read = (name: string) => evaluate(`var(${name})`, tokens); + const page = read("--fe-bg"); + if (page.alpha < 1) throw new Error(`${preset.id}: the page is not opaque`); + const surfaces: Record = { + page, + panel: composite(read("--fe-surface"), page), + raised: composite(read("--fe-surface-2"), page), + "deepest tone": composite(read("--fe-tone-3"), page), + }; + const out: Measure[] = []; + const add = (pair: string, fg: Rgba, bg: Rgba, minimum: number) => + out.push({ + pair: `${pair}: ${toHex(composite(fg, bg))} on ${toHex(bg)}`, + ratio: contrastRatio(fg, bg), + minimum, + }); + for (const [ground, colour] of Object.entries(surfaces)) { + for (const ink of ["--fe-ink", "--fe-ink-2", "--fe-ink-3"]) { + add(`${ink} on ${ground}`, read(ink), colour, TEXT); + } + } + for (const ground of ["page", "panel", "raised"]) { + add( + `--accent as text on ${ground}`, + read("--accent"), + surfaces[ground] as Rgba, + TEXT, + ); + } + add( + "--accent as an edge beside the playing row's tone", + read("--accent"), + composite(read("--fe-tone-2"), page), + INDICATOR, + ); + add( + "--fe-accent-ink on a filled --accent", + read("--fe-accent-ink"), + composite(read("--accent"), page), + TEXT, + ); + return out; +} + +describe("contrast across every preset", () => { + it.each(THEME_PRESETS.map((preset) => [preset.id, preset] as const))( + "%s holds WCAG AA on every pairing its material makes", + (_id, preset) => { + const failing = measure(preset) + .filter(({ ratio, minimum }) => ratio < minimum) + .map( + ({ pair, ratio, minimum }) => + `${pair} is ${ratio.toFixed(2)}:1, below ${minimum}:1`, + ); + expect(failing).toEqual([]); + }, + ); + + it("reads the theme's own tokens, not the sheet's defaults", () => { + // A resolver that ignored `applyTheme` would measure Emerald fourteen + // times and pass the other thirteen for nothing. + const accents = new Set( + THEME_PRESETS.map((preset) => + toHex(evaluate("var(--accent)", tokensFor(preset))), + ), + ); + expect(accents.size).toBeGreaterThanOrEqual(7); + const grounds = new Set( + THEME_PRESETS.filter((preset) => preset.mode === "light").map((preset) => + toHex(evaluate("var(--fe-bg)", tokensFor(preset))), + ), + ); + expect(grounds.size).toBe(6); + }); +}); diff --git a/webapp/src/styles/fluid.css b/webapp/src/styles/fluid.css index e9190c65..477c13e2 100644 --- a/webapp/src/styles/fluid.css +++ b/webapp/src/styles/fluid.css @@ -158,7 +158,10 @@ --fe-surface-2: #fff; --fe-ink: #1b1a17; --fe-ink-2: #46433d; - --fe-ink-3: #625e56; + /* #625e56 until the tinted grounds were measured: on Midnight, Lavender + and Crimson's deepest tone it fell to 4.41–4.47:1. One step darker holds + 4.62 on the worst of them (`themes-contrast.test.ts`). */ + --fe-ink-3: #5f5b53; --fe-hairline: rgb(27 26 23 / 9%); --fe-hairline-strong: rgb(27 26 23 / 16%); --fe-tone-1: rgb(27 26 23 / 4%); @@ -195,6 +198,14 @@ --fe-surface-2: #121216; } +/* Sunset's amber 700 is the lightest of the light accents and its ground the + warmest: together they read 4.29:1, short of AA for the links and kickers + the accent is spent on. A fifth of the 800 stop takes it to 4.58 and keeps + the orange; the palette itself stays the desktop's. */ +:root:not(.dark)[data-theme="sunset-light"] { + --accent: color-mix(in srgb, var(--accent-700) 80%, var(--accent-800)); +} + /* ── Ground and atmosphere ───────────────────────────────────── */ :where(:root) body { From 2a216045da14493001de6a1fa757315816f279af Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 07:23:25 +0100 Subject: [PATCH 2/6] feat(webapp): a card picker for the themes that can follow the system Each preset is a card with a miniature drawn from its own tokens, grouped under Light and Dark, over native radios that give the keyboard and the tab order. A per-browser preference beside waveflow.theme switches between a preset and its pair with prefers-color-scheme, live. Signed-off-by: InstaZDLL --- webapp/src/components/theme-picker.tsx | 208 ++++++++++++++++ webapp/src/pages/settings-page.tsx | 3 +- webapp/src/state/i18n.tsx | 26 ++ webapp/src/state/preferences.tsx | 172 ++++++++++--- webapp/src/styles/design-tokens/apply.test.ts | 26 +- webapp/src/styles/design-tokens/apply.ts | 25 ++ webapp/src/styles/design-tokens/index.ts | 3 +- .../src/styles/design-tokens/themes.test.ts | 40 +++- webapp/src/styles/design-tokens/themes.ts | 23 ++ webapp/src/styles/fluid.css | 226 ++++++++++++++++++ 10 files changed, 710 insertions(+), 42 deletions(-) create mode 100644 webapp/src/components/theme-picker.tsx diff --git a/webapp/src/components/theme-picker.tsx b/webapp/src/components/theme-picker.tsx new file mode 100644 index 00000000..e0185f8c --- /dev/null +++ b/webapp/src/components/theme-picker.tsx @@ -0,0 +1,208 @@ +import { type CSSProperties, useId } from "react"; +import { type TranslationKey, useI18n } from "../state/i18n"; +import { useThemePreference } from "../state/preferences"; +import { + findTheme, + THEME_PRESETS, + type ThemeMode, + type ThemePreset, + themeSurfaces, +} from "../styles/design-tokens"; + +const MODES: ReadonlyArray<{ mode: ThemeMode; label: TranslationKey }> = [ + { mode: "light", label: "preferences.themeLight" }, + { mode: "dark", label: "preferences.themeDark" }, +]; + +/** + * The cards in the order they are drawn, and so walked by the arrow keys: the + * light row, then the dark one, each in the catalogue's order. + */ +const ORDER: ReadonlyArray = MODES.flatMap(({ mode }) => + THEME_PRESETS.filter((preset) => preset.mode === mode), +); + +/** + * One preset's miniature, as custom properties the card's stylesheet reads. + * + * Drawn from the preset's own tokens — its ground, panel and raised surface, + * and its accent at the stops the material spends — so every card shows its + * own theme whichever one is applied, the way the desktop's picker does. + */ +function previewStyle(preset: ThemePreset): CSSProperties { + const surfaces = themeSurfaces(preset); + const dark = preset.mode === "dark"; + return { + "--preview-ground": surfaces.ground, + "--preview-surface": surfaces.surface, + "--preview-raised": surfaces.raised, + "--preview-ink": dark ? "#f3f1ec" : "#1b1a17", + "--preview-accent": dark ? preset.accent[400] : preset.accent[600], + "--preview-accent-deep": dark ? preset.accent[600] : preset.accent[800], + } as CSSProperties; +} + +/** + * The theme picker: a card per preset, under a Light and a Dark heading, and + * the box that hands the choice between a preset and its pair to the device. + * + * **Native radios under the cards.** Each card is the label of a real + * ``, all fourteen under one name, so the browser does + * what a radio group must and a hand-rolled one gets wrong somewhere: one + * stop in the tab order (the checked card), arrow keys that move and select + * across both rows and wrap, Space on a focused card, and the checked state + * read out. The cards draw the state; the inputs carry it. + * + * **The checked card is the one on screen.** While following the device, + * picking Midnight light at night checks Midnight: the pick chose the + * family, and the device chose its member. + */ +export function ThemePicker() { + const { t } = useI18n(); + const preferences = useThemePreference(); + const headingId = useId(); + const name = (preset: ThemePreset) => t(preset.labelKey as TranslationKey); + + const chosen = findTheme(preferences.chosenThemeId); + const shown = findTheme(preferences.themeId); + // A one-off with no pair (OLED, Neon) cannot follow the device across: + // Emerald stands in, and the page says so instead of seeming to ignore the + // pick. + const standIn = + preferences.followSystem && + shown.id !== chosen.id && + shown.id !== chosen.pair; + + return ( +
+

+ {t("preferences.theme")} +

+ {preferences.systemScheme !== null ? ( +
+ +

+ {!preferences.followSystem + ? t("preferences.followSystemDetail") + : preferences.systemScheme === "dark" + ? t("preferences.followingDark") + : t("preferences.followingLight")} +

+ {standIn ? ( +

+ {t( + shown.mode === "light" + ? "preferences.standInLight" + : "preferences.standInDark", + { theme: name(chosen), fallback: name(shown) }, + )} +

+ ) : null} +
+ ) : null} +
+ {MODES.map(({ mode, label }) => { + const groupHeadingId = `${headingId}-${mode}`; + return ( +
+
+ {t(label)} +
+
+ {ORDER.filter((preset) => preset.mode === mode).map( + (preset) => { + const checked = preset.id === preferences.themeId; + const nameId = `${headingId}-${preset.id}-name`; + const modeId = `${headingId}-${preset.id}-mode`; + return ( + + ); + }, + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/webapp/src/pages/settings-page.tsx b/webapp/src/pages/settings-page.tsx index 2736fa18..d6e953a4 100644 --- a/webapp/src/pages/settings-page.tsx +++ b/webapp/src/pages/settings-page.tsx @@ -9,8 +9,9 @@ import { } from "../api"; import { ownApiTokensQuery, ownSubsonicCredentialQuery } from "../api/queries"; import { Loading, PageHeader, useReload } from "../components/page-shell"; +import { ThemePicker } from "../components/theme-picker"; import { LanguagePicker, type Locale, useI18n } from "../state/i18n"; -import { ReplayGainPicker, ThemePicker } from "../state/preferences"; +import { ReplayGainPicker } from "../state/preferences"; /** * The Account section of Settings, the clients allowed to read the library, diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 4011825d..e907155e 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -31,6 +31,19 @@ const en = { "nav.moreClose": "Close", "preferences.theme": "Theme", "preferences.language": "Language", + "preferences.themeLight": "Light", + "preferences.themeDark": "Dark", + "preferences.followSystem": "Follow the system", + "preferences.followSystemDetail": + "Switch between a theme’s light and dark versions when your device does.", + "preferences.followingLight": + "Your device is in light mode, so the light version is shown. Picking a theme picks both of its versions.", + "preferences.followingDark": + "Your device is in dark mode, so the dark version is shown. Picking a theme picks both of its versions.", + "preferences.standInLight": + "{theme} has no light version, so {fallback} stands in while your device is in light mode.", + "preferences.standInDark": + "{theme} has no dark version, so {fallback} stands in while your device is in dark mode.", "replayGain.label": "ReplayGain", "replayGain.off": "Off", "replayGain.track": "Track gain", @@ -1168,6 +1181,19 @@ const fr: Record = { "nav.moreClose": "Fermer", "preferences.theme": "Thème", "preferences.language": "Langue", + "preferences.themeLight": "Clair", + "preferences.themeDark": "Sombre", + "preferences.followSystem": "Suivre le système", + "preferences.followSystemDetail": + "Passer de la version claire d’un thème à sa version sombre en même temps que votre appareil.", + "preferences.followingLight": + "Votre appareil est en mode clair : la version claire est affichée. Choisir un thème choisit ses deux versions.", + "preferences.followingDark": + "Votre appareil est en mode sombre : la version sombre est affichée. Choisir un thème choisit ses deux versions.", + "preferences.standInLight": + "{theme} n’a pas de version claire : {fallback} le remplace tant que votre appareil est en mode clair.", + "preferences.standInDark": + "{theme} n’a pas de version sombre : {fallback} le remplace tant que votre appareil est en mode sombre.", "replayGain.label": "ReplayGain", "replayGain.off": "Désactivé", "replayGain.track": "Gain par piste", diff --git a/webapp/src/state/preferences.tsx b/webapp/src/state/preferences.tsx index 74a3df04..675c6d49 100644 --- a/webapp/src/state/preferences.tsx +++ b/webapp/src/state/preferences.tsx @@ -5,6 +5,7 @@ import { useContext, useEffect, useId, + useLayoutEffect, useState, } from "react"; import { @@ -19,12 +20,20 @@ import { import { applyTheme, findTheme, - THEME_PRESETS, + type ThemeMode, type ThemePreset, + themeForScheme, } from "../styles/design-tokens"; import { type TranslationKey, useI18n } from "./i18n"; const STORAGE_KEY = "waveflow.theme"; +/** + * Per browser, beside the theme it qualifies: whether the device's light or + * dark setting chooses between a preset and its pair. A phone and a desktop + * on one account can disagree about it, as they can about the theme. + */ +const FOLLOW_SYSTEM_KEY = "waveflow.themeFollowSystem"; +const DARK_SCHEME = "(prefers-color-scheme: dark)"; /** * Per browser, like the volume and the theme: how loud a device should be is * not a property of the account (RFC-022 decision 1). @@ -32,8 +41,15 @@ const STORAGE_KEY = "waveflow.theme"; const REPLAY_GAIN_KEY = "waveflow.replayGain"; type Preferences = { + /** The preset on screen: the chosen one, or its pair while following. */ themeId: string; + /** The preset last picked, which is what is stored. */ + chosenThemeId: string; setThemeId: (id: string) => void; + followSystem: boolean; + setFollowSystem: (follow: boolean) => void; + /** The device's scheme, or null where the browser cannot say. */ + systemScheme: ThemeMode | null; replayGain: ReplayGainPreference; setReplayGain: (preference: ReplayGainPreference) => void; }; @@ -49,26 +65,94 @@ function readStoredReplayGain(): ReplayGainPreference { } } +function readStoredTheme(): ThemePreset { + try { + return findTheme(localStorage.getItem(STORAGE_KEY)); + } catch { + return findTheme(null); + } +} + +/** Off unless this browser turned it on: a first visit keeps the default. */ +function readStoredFollowSystem(): boolean { + try { + return localStorage.getItem(FOLLOW_SYSTEM_KEY) === "true"; + } catch { + return false; + } +} + +/** The device's scheme query, where the browser has one. */ +function schemeQuery(): MediaQueryList | null { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") + return null; + try { + return window.matchMedia(DARK_SCHEME); + } catch { + return null; + } +} + +function readSystemScheme(): ThemeMode | null { + const query = schemeQuery(); + return query ? (query.matches ? "dark" : "light") : null; +} + +/** + * The preset to paint: the chosen one, or — while following a scheme the + * browser can report — the member of its family that suits that scheme. + */ +function resolveTheme( + chosenId: string, + followSystem: boolean, + scheme: ThemeMode | null, +): ThemePreset { + const chosen = findTheme(chosenId); + return followSystem && scheme ? themeForScheme(chosen, scheme) : chosen; +} + export function PreferencesProvider({ children }: { children: ReactNode }) { - const [themeId, setStoredThemeId] = useState(() => { - let theme: ThemePreset; + const [chosenThemeId, setChosenThemeId] = useState( + () => readStoredTheme().id, + ); + const [followSystem, setFollowSystemState] = useState(readStoredFollowSystem); + const [systemScheme, setSystemScheme] = useState(readSystemScheme); + const [replayGain, setReplayGainState] = useState(readStoredReplayGain); + + const theme = resolveTheme(chosenThemeId, followSystem, systemScheme); + // Painted during the first render rather than after it, so the page never + // shows one frame of the default before the stored theme; then again + // whenever the choice, the box or the device changes it. + useState(() => applyTheme(theme)); + useLayoutEffect(() => applyTheme(theme), [theme]); + + // Live: a device that turns dark at sunset takes the page with it. Heard + // only while following, since nothing else reads the scheme. + useEffect(() => { + if (!followSystem) return; + const query = schemeQuery(); + if (!query) return; + const update = () => setSystemScheme(query.matches ? "dark" : "light"); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, [followSystem]); + + useEffect(() => { try { - theme = findTheme(localStorage.getItem(STORAGE_KEY)); + localStorage.setItem(STORAGE_KEY, chosenThemeId); } catch { - theme = findTheme(null); + // Appearance remains usable when persistent storage is disabled. } - applyTheme(theme); - return theme.id; - }); - const [replayGain, setReplayGainState] = useState(readStoredReplayGain); + }, [chosenThemeId]); useEffect(() => { try { - localStorage.setItem(STORAGE_KEY, themeId); + localStorage.setItem(FOLLOW_SYSTEM_KEY, String(followSystem)); } catch { - // Appearance remains usable when persistent storage is disabled. + // Following still works for this session without persistence. } - }, [themeId]); + }, [followSystem]); useEffect(() => { try { @@ -79,11 +163,20 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { }, [replayGain]); const setThemeId = useCallback((id: string) => { - const theme = findTheme(id); - applyTheme(theme); - setStoredThemeId(theme.id); + setChosenThemeId(findTheme(id).id); }, []); + const setFollowSystem = useCallback( + (follow: boolean) => { + // Turning it off keeps what is on screen: the page should not jump back + // to the other half of the family the moment the box is cleared. + if (!follow) setChosenThemeId(theme.id); + else setSystemScheme(readSystemScheme()); + setFollowSystemState(follow); + }, + [theme], + ); + const setReplayGain = useCallback((preference: ReplayGainPreference) => { setReplayGainState({ mode: preference.mode, @@ -93,13 +186,38 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { return ( {children} ); } +/** The theme preference, for the picker that sets it. */ +export function useThemePreference(): Pick< + Preferences, + | "themeId" + | "chosenThemeId" + | "setThemeId" + | "followSystem" + | "setFollowSystem" + | "systemScheme" +> { + const preferences = useContext(PreferencesContext); + if (!preferences) + throw new Error("useThemePreference requires PreferencesProvider"); + return preferences; +} + /** The ReplayGain preference, for the player that applies it. */ export function useReplayGainPreference(): ReplayGainPreference { const preferences = useContext(PreferencesContext); @@ -108,28 +226,6 @@ export function useReplayGainPreference(): ReplayGainPreference { return preferences.replayGain; } -export function ThemePicker() { - const preferences = useContext(PreferencesContext); - const { t } = useI18n(); - if (!preferences) throw new Error("ThemePicker requires PreferencesProvider"); - return ( - - ); -} - const MODE_LABELS: Record = { off: "replayGain.off", track: "replayGain.track", diff --git a/webapp/src/styles/design-tokens/apply.test.ts b/webapp/src/styles/design-tokens/apply.test.ts index 072bad62..a8d5d6e5 100644 --- a/webapp/src/styles/design-tokens/apply.test.ts +++ b/webapp/src/styles/design-tokens/apply.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { applyTheme, themeCssDeclarations } from "./apply"; +import { applyTheme, themeCssDeclarations, themeSurfaces } from "./apply"; import { findTheme, THEME_PRESETS } from "./themes"; describe("applyTheme", () => { @@ -82,3 +82,27 @@ describe("themeCssDeclarations", () => { expect(css).toContain("--color-surface-light: #ffffff;"); }); }); + +describe("themeSurfaces", () => { + afterEach(() => { + document.documentElement.removeAttribute("style"); + }); + + it("names the surfaces applyTheme writes for the preset's own mode", () => { + // The picker's miniatures draw from these; a card that disagreed with + // the theme it previews would show a colour the theme never paints. + const root = document.documentElement; + for (const preset of THEME_PRESETS) { + applyTheme(preset); + const prefix = preset.mode === "dark" ? "dark" : "light"; + const surfaces = themeSurfaces(preset); + expect(surfaces.ground).toBe(root.style.getPropertyValue("--ambient-bg")); + expect(surfaces.surface).toBe( + root.style.getPropertyValue(`--color-surface-${prefix}`), + ); + expect(surfaces.raised).toBe( + root.style.getPropertyValue(`--color-surface-${prefix}-elevated`), + ); + } + }); +}); diff --git a/webapp/src/styles/design-tokens/apply.ts b/webapp/src/styles/design-tokens/apply.ts index 8e0272c8..1229fe31 100644 --- a/webapp/src/styles/design-tokens/apply.ts +++ b/webapp/src/styles/design-tokens/apply.ts @@ -60,6 +60,31 @@ export function applyTheme(theme: ThemePreset): void { } } +/** + * The three surfaces a preset paints in its own mode — the page's ground, a + * panel and a raised panel — with the defaults `applyTheme` falls back to. + * The Settings picker draws each preset's miniature from these, so a card + * shows the preset's own colours rather than whichever theme is applied. + */ +export function themeSurfaces(theme: ThemePreset): { + ground: string; + surface: string; + raised: string; +} { + if (theme.mode === "dark") { + return { + ground: theme.ambient ?? "#121212", + surface: theme.surfaceDark ?? DEFAULT_SURFACE_DARK, + raised: theme.surfaceDarkElevated ?? DEFAULT_SURFACE_DARK_ELEVATED, + }; + } + return { + ground: theme.ambient ?? "#ffffff", + surface: theme.surfaceLight ?? DEFAULT_SURFACE_LIGHT, + raised: theme.surfaceLightElevated ?? DEFAULT_SURFACE_LIGHT_ELEVATED, + }; +} + /** * Serialize a preset to a `