fix(design): rating and live colour tokens, the player's menus over its notices, the info overlay on a light theme (E28)

- F16: --rating (theme-scoped gold, 4.5:1 on the page and its chip), --rating-media, --live/--live-fg,
  --media-chrome replace 14 palette literals; a lint rule refuses palette classes; the theme contract
  measures them.
- The Plex player's notices drew over its open settings/tracks popovers: notices z-base, popovers z-menu.
- The player's info overlay rendered theme tokens on its always-dark panel: on a light theme the score
  (2.6:1), collection years (3.2:1) and cast initials (1.05:1) were unreadable; onDark variants now.
- The lint-rule contract tests share one ESLint with a one-off warm-up (a cold first lint took 8 s,
  past vitest's 5 s).
This commit is contained in:
2026-09-25 13:55:26 +02:00
parent 295c1db5f1
commit ff80cc96fd
28 changed files with 201 additions and 100 deletions
+25 -1
View File
@@ -30,6 +30,25 @@ function block(selector: string): Record<string, string> {
const status = (mode: Mode) => block(`html[data-theme="${mode}"]`);
const scheme = (id: string, mode: Mode) => block(`html[data-scheme="${id}"][data-theme="${mode}"]`);
// Theme-independent colours: what sits on media (a thumbnail, a poster, the player) looks the same in
// dark and light, because the picture under it does.
const media = () => block(":root");
// F16: a rating's gold and a broadcast's red are their own tokens, not palette literals and not a status
// (a score is not a warning, "live" is not an error).
describe("the media colours", () => {
it("the live badge's text holds 4.5:1 on its fill", () => {
expect(hexContrast(media()["live-fg"]!, media().live!)).toBeGreaterThanOrEqual(4.5);
});
// A poster card's score sits on a black 85–95% scrim over any picture: the worst case is 85% black
// over white, #262626.
it("a score on a poster's scrim holds 4.5:1", () => {
expect(hexContrast(media()["rating-media"]!, "#262626")).toBeGreaterThanOrEqual(4.5);
});
it("the player chrome's white text holds 4.5:1", () => {
expect(hexContrast("#ffffff", media()["media-chrome"]!)).toBeGreaterThanOrEqual(4.5);
});
});
it("styles exactly the schemes the app offers", () => {
const inCss = new Set(
@@ -94,8 +113,13 @@ describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
danger: hexContrast(s.danger!, bg),
warn: hexContrast(s.warn!, bg),
success: hexContrast(s.success!, bg),
// A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text.
rating: hexContrast(s.rating!, bg),
ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)),
live: hexContrast(media().live!, bg),
};
const failing = Object.entries(measured).filter(([k, v]) => v < (k === "edge" ? 3 : 4.5));
const nonText = new Set(["edge", "live"]);
const failing = Object.entries(measured).filter(([k, v]) => v < (nonText.has(k) ? 3 : 4.5));
expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
});