Files
gacha-event-tracker/test/theme.test.ts
T
Lucas WintherandClaude Opus 5.5 66d6748070 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 <[email protected]>
2026-10-01 03:44:20 +02:00

182 lines
6.8 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { GAMES, metaFor } from "../src/shared/games.ts";
import { CUSTOM_HUES } from "../src/client/components/CustomForms.tsx";
import { KEYS } from "../src/client/state/storage.ts";
import {
DEFAULT_THEME_CHOICE,
metaOnTheme,
readableHue,
resolveTheme,
THEME_COLOR,
} from "../src/client/state/theme.ts";
/**
* Light mode.
*
* Three things are worth pinning and one of them is the whole risk. The dark
* theme is what this app is and what every hue in `games.ts` was picked
* against, so a light theme that moves it has broken something nobody asked to
* change. The light theme has to be *readable* — a hue is a lane label at
* 10px, and one that washes out on paper takes a game's identity with it. And
* the ground colour is written down in three places that cannot import each
* other (the stylesheet, this module, the pre-paint script in the shell), so
* the copies are checked against each other rather than trusted.
*/
// An independent implementation of WCAG contrast — deliberately not the one in
// theme.ts, so this measures the output rather than agreeing with the method.
function luminance(hex: string): number {
const body = hex.replace("#", "");
const channels = [0, 2, 4].map((i) => {
const s = parseInt(body.slice(i, i + 2), 16) / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
}) as [number, number, number];
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [
number,
number,
];
return (hi + 0.05) / (lo + 0.05);
}
const HUES = [
...Object.values(GAMES).map((game) => game.hue),
...CUSTOM_HUES,
// What an unknown lane is drawn in — an import can carry an event whose game
// did not come with it.
metaFor("mygame:nothing-here", {}).hue,
];
describe("resolveTheme", () => {
test("dark is the default, and it is a side rather than the device's", () => {
expect(DEFAULT_THEME_CHOICE).toBe("dark");
expect(resolveTheme(DEFAULT_THEME_CHOICE, true)).toBe("dark");
});
test("a reader who picked a side gets it whatever the device says", () => {
expect(resolveTheme("dark", true)).toBe("dark");
expect(resolveTheme("light", false)).toBe("light");
});
test("system is the device's answer, both ways", () => {
expect(resolveTheme("system", true)).toBe("light");
expect(resolveTheme("system", false)).toBe("dark");
});
});
describe("readableHue", () => {
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", () => {
for (const hue of HUES) {
const adjusted = readableHue(hue, "light");
expect(contrast(adjusted, THEME_COLOR.light)).toBeGreaterThanOrEqual(4.5);
}
});
test("a hue that already reads is left alone", () => {
// Fate's navy is darker than the paper to begin with; darkening it further
// would cost a game its identity to fix a problem it does not have.
expect(readableHue(GAMES.fgo.hue, "light")).toBe(GAMES.fgo.hue);
});
test("darkening keeps the colour, not just the contrast", () => {
// Wuthering Waves is green before and after. A hue is an identity, so a
// legibility fix that turned every lane into the same slate would be worse
// than the illegibility.
const green = readableHue(GAMES.wuwa.hue, "light");
const [r, g, b] = [1, 3, 5].map((i) => parseInt(green.slice(i, i + 2), 16));
expect(g).toBeGreaterThan(r!);
expect(g).toBeGreaterThan(b!);
});
test("a colour we cannot read is not reinterpreted", () => {
// Custom hues are stored in the reader's browser and could be anything.
// Guessing at one we do not understand is worse than leaving it.
for (const odd of ["", "rebeccapurple", "var(--color-ink)", "#12345"]) {
expect(readableHue(odd, "light")).toBe(odd);
}
});
test("shorthand hex is understood rather than passed through", () => {
expect(readableHue("#3d6", "light")).toBe(readableHue("#33dd66", "light"));
});
});
describe("metaOnTheme", () => {
const meta = GAMES.wuwa;
test("names and clocks are untouched; only the hue is answered", () => {
const light = metaOnTheme(meta, "light");
expect(light.hue).not.toBe(meta.hue);
expect({ ...light, hue: meta.hue }).toEqual(meta);
});
test("the dark answer is the same object, so nothing re-renders for it", () => {
expect(metaOnTheme(meta, "dark")).toBe(meta);
});
});
describe("the ground colour, in all three places it is written down", () => {
test("the stylesheet and the module agree", async () => {
const css = await Bun.file(
new URL("../src/client/styles.css", import.meta.url),
).text();
// The dark value is in @theme, the light one under the attribute selector.
const dark = /@theme\s*\{[^}]*?--color-ground:\s*([^;]+);/s.exec(css);
const light =
/\[data-theme="light"\]\s*\{[^}]*?--color-ground:\s*([^;]+);/s.exec(css);
expect(dark?.[1]?.trim()).toBe(THEME_COLOR.dark);
expect(light?.[1]?.trim()).toBe(THEME_COLOR.light);
});
test("the shell paints the right ground before the bundle arrives", async () => {
const shell = await Bun.file(
new URL("../index.html", import.meta.url),
).text();
// The pre-paint script is the only reason a reader on light does not get a
// dark flash on every load, and it cannot import any of this.
expect(shell).toContain(KEYS.prefs);
expect(shell).toContain(THEME_COLOR.dark);
expect(shell).toContain(THEME_COLOR.light);
});
});