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:
co-authored by
Claude Opus 5.5
parent
12a3947e96
commit
66d6748070
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user