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]>
182 lines
6.8 KiB
TypeScript
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);
|
|
});
|
|
});
|