diff --git a/AGENTS.md b/AGENTS.md index 4781f38..29e12a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1048,8 +1048,12 @@ to an open page). Four things hold it up: pinned by `test/theme.test.ts`. Two rules follow. **The heat ramp must stay readable on whichever ground it is on** — it carries meaning, and the dark theme's amber is 1.9:1 on paper, so each step is re-struck rather than - reused. And **the dark theme does not move**: `readableHue` returns dark untouched by - construction, because adding a theme is not a licence to redraw the one that shipped. + reused. And **the dark theme moves only where it failed**: `readableHue` returns a dark hue + untouched when it already clears 4.5:1 on the dark ground, and lifts the lightness of the + ones that do not (repository owner, 2026-10-01). This rule used to say dark never moved, on + the claim that every hue cleared the bar there; five did not, and Fate's navy was 1.8:1, so + the game name under the headline deadline was close to invisible. Fixing a failure is not + the same as redrawing the theme that shipped, and the fifteen hues that pass do not change. - **The page is two columns past `lg`, and the split is the one below.** What the page *tells* the reader to do — the next deadlines, tonight's dailies — pins to a rail on the left and stays put while the lists it *shows* them scroll beside it. Below that breakpoint it is one column in the diff --git a/docs/PRD.md b/docs/PRD.md index 2941c41..3b9bfec 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -492,7 +492,9 @@ wrong: - **A game keeps its colour.** The hues are identity (F1) and were all picked against a near-black ground; on paper the bright ones vanish. They are darkened until they read, along the same hue, so Wuthering Waves is still the green one — including the hues a reader picked for a game they - invented (F13). + invented (F13). The same bar holds on dark: five hues were too dim for the ground they were + picked against (Fate's navy at 1.8:1), so those alone are lifted in lightness until they clear + 4.5:1, and every hue that already read is left exactly as it shipped. Switching is instant, costs nothing and saves nothing: no reload, and nothing marked, typed or ticked is touched. And it survives the load it is chosen on — the shell sets the theme before first diff --git a/src/client/state/theme.ts b/src/client/state/theme.ts index 7f1c110..abd9941 100644 --- a/src/client/state/theme.ts +++ b/src/client/state/theme.ts @@ -12,9 +12,10 @@ import type { GameMeta } from "../../shared/games.ts"; * - **Resolving the reader's answer.** "System" is a choice about a preference * they set elsewhere, and it has to be read and watched. * - **The game hues.** They are data, not tokens — `games.ts` for the games we - * track, the reader's own typing for theirs — and they were all picked - * against a near-black ground. On paper the brighter ones are unreadable, so - * they are darkened until they are not. + * track, the reader's own typing for theirs — and they were picked for + * identity, not measured. On paper the brighter ones are unreadable, so they + * are darkened until they are not; on the dark ground the few that are too + * dim are lifted the same way. * - **The browser's own chrome.** `` is markup, so it * is set from here rather than styled. */ @@ -62,7 +63,7 @@ export function resolveTheme( } // --------------------------------------------------------------------------- -// Game hues on a light ground +// Game hues on either ground // --------------------------------------------------------------------------- /** @@ -114,13 +115,49 @@ function contrast(a: [number, number, number], b: [number, number, number]): num } const LIGHT_GROUND = parseHex(THEME_COLOR.light) as [number, number, number]; +const DARK_GROUND = parseHex(THEME_COLOR.dark) as [number, number, number]; + +/** RGB channels as hue (0–1), saturation and lightness. */ +function toHsl([r, g, b]: [number, number, number]): [number, number, number] { + const [rs, gs, bs] = [r / 255, g / 255, b / 255]; + const max = Math.max(rs, gs, bs); + const min = Math.min(rs, gs, bs); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + const h = + max === rs + ? ((gs - bs) / d + (gs < bs ? 6 : 0)) / 6 + : max === gs + ? ((bs - rs) / d + 2) / 6 + : ((rs - gs) / d + 4) / 6; + return [h, s, l]; +} + +function fromHsl([h, s, l]: [number, number, number]): [number, number, number] { + const k = (n: number) => (n + h * 12) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n: number) => + l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + return [f(0), f(8), f(4)].map((c) => Math.round(c * 255)) as [ + number, + number, + number, + ]; +} /** - * The same hue, dark enough to read on this theme's ground. + * The same hue, readable on this theme's ground. * - * Dark is returned untouched, always: those hues were chosen against that - * ground and every one of them clears the bar there, so adding a theme must - * not move a single pixel of the app as it shipped. + * On dark, a hue that already clears the bar is returned untouched — which is + * fifteen of the twenty games, and those pixels do not move. The five that do + * not were picked for identity rather than measured: Fate's navy is 1.8:1 on + * the near-black ground, which made "Fate/Grand Order" under the headline + * deadline nearly invisible, and Punishing, Persona, Chaos Zero and Honkai 3rd + * sit between 3.4 and 4.3. Those are lifted in lightness alone, so the angle + * and saturation that say which game it is survive and only the value moves — + * Fate stays a blue, Punishing stays a red. * * On light it is a scale towards black — the channels keep their ratios, so * Genshin's blue stays Genshin's blue rather than becoming a computed @@ -129,9 +166,22 @@ const LIGHT_GROUND = parseHex(THEME_COLOR.light) as [number, number, number]; * colour is not ours to reinterpret when we do not understand it. */ export function readableHue(hue: string, theme: Theme): string { - if (theme === "dark") return hue; const rgb = parseHex(hue); if (rgb === null) return hue; + if (theme === "dark") { + if (contrast(rgb, DARK_GROUND) >= MIN_HUE_CONTRAST) return hue; + const [h, s, l] = toHsl(rgb); + // Bisect the lightness, measuring the rounded colour that will actually + // be written, for the same reason as the light branch below. + let tooDim = l; + let enough = 1; + for (let i = 0; i < 20; i++) { + const mid = (tooDim + enough) / 2; + if (contrast(fromHsl([h, s, mid]), DARK_GROUND) >= MIN_HUE_CONTRAST) enough = mid; + else tooDim = mid; + } + return toHex(fromHsl([h, s, enough])); + } if (contrast(rgb, LIGHT_GROUND) >= MIN_HUE_CONTRAST) return hue; // Bisect the scale factor, measuring the colour that will actually be diff --git a/test/theme.test.ts b/test/theme.test.ts index 31bcab0..abf3ae9 100644 --- a/test/theme.test.ts +++ b/test/theme.test.ts @@ -68,10 +68,39 @@ describe("resolveTheme", () => { }); describe("readableHue", () => { - test("dark is untouched, hue for hue", () => { - // The one thing adding a theme must not do is change the theme that was - // already there. These colours were chosen against this ground. - for (const hue of HUES) expect(readableHue(hue, "dark")).toBe(hue); + test("on dark, a hue that already reads is untouched, hue for hue", () => { + // Lifting the dim few is a fix; moving the rest would be redrawing the + // theme that shipped. + for (const hue of HUES) { + if (contrast(hue, THEME_COLOR.dark) >= 4.5) { + expect(readableHue(hue, "dark")).toBe(hue); + } + } + }); + + test("every hue reads on the dark ground", () => { + // Fate's navy was 1.8:1 here — the game name under the headline deadline + // was all but invisible. + for (const hue of HUES) { + const adjusted = readableHue(hue, "dark"); + expect(contrast(adjusted, THEME_COLOR.dark)).toBeGreaterThanOrEqual(4.5); + } + }); + + test("lifting on dark keeps the colour, not just the contrast", () => { + // Only lightness moves, so Punishing's red is still red and Fate's navy + // is still blue rather than a grey that happens to pass. + const red = readableHue(GAMES.pgr.hue, "dark"); + const blue = readableHue(GAMES.fgo.hue, "dark"); + const rgb = (hex: string) => + [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number]; + const [rr, rg, rb] = rgb(red); + const [br, bg, bb] = rgb(blue); + expect(red).not.toBe(GAMES.pgr.hue); + expect(rr).toBeGreaterThan(rg); + expect(rr).toBeGreaterThan(rb); + expect(bb).toBeGreaterThan(br); + expect(bb).toBeGreaterThan(bg); }); test("every hue reads on the light ground", () => {