fix(theme): lift game hues that are unreadable on the dark ground

readableHue returned every dark hue untouched on the claim that all of
them already cleared 4.5:1 there. Five did not, and Fate's navy was
1.8:1, so its name under the headline deadline was nearly invisible.
Those five are now lifted in lightness only, keeping hue and saturation;
the fifteen that pass are unchanged. The owner approved relaxing the
"dark theme does not move" rule to this, and AGENTS.md and PRD F15 say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-01 03:44:20 +02:00
co-authored by Claude Opus 5.5
parent 12a3947e96
commit 66d6748070
4 changed files with 101 additions and 16 deletions
+6 -2
View File
@@ -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
+3 -1
View File
@@ -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
+59 -9
View File
@@ -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.** `<meta name="theme-color">` 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
+33 -4
View File
@@ -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", () => {