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
@@ -1,16 +1,13 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/button-primitive` (eslint.config.js): one primitive per button family (docs/DESIGN.md, E28
// S3b). 268 hand-rolled buttons were swept onto ui/Button, IconButton, Pressable, Chip and
// DragHandle; this pins that the rule keeping a new one out actually fires, and where it stands down.
const RULE = "local/button-primitive";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
@@ -1,15 +1,12 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// Two lint rules about controls: no native <select> (the canonical "FE control" row in CLAUDE.md;
// six had drifted in before the rule existed), and no hover gated on `enabled:` alone. A selector
// that matches nothing looks exactly like a clean codebase, so this pins that each fires.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function refused(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith("No native <select>")).length;
}
@@ -25,7 +22,6 @@ describe("a native select", () => {
// which then lit up on hover as if a click would work.
describe("a hover gated on `enabled:` alone", () => {
it("is refused; the aria-aware gate is not", async () => {
const eslint = new ESLint({ cwd: FRONTEND });
const lint = async (cls: string) =>
(
await eslint.lintText(
+2 -5
View File
@@ -1,16 +1,13 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/focus-safe-disabled` (eslint.config.js): a raw `<button disabled>` throws a focused keyboard
// user to <body> the moment it turns disabled. ~60 were swept onto ui/FocusSafeButton; this pins that
// the rule keeping a new one out actually fires.
const RULE = "local/focus-safe-disabled";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
+26 -8
View File
@@ -1,20 +1,18 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// Two E28-audit rules in eslint.config.js, pinned so a selector that stops matching cannot pass for a
// clean codebase: a hover that swaps a colour instead of the B3 state layer, and an <a> that holds only
// a glyph (DESIGN.md "A link that looks like a button" is IconLink).
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
import { eslint } from "./lintProbe.testkit";
// Three E28-audit rules in eslint.config.js, pinned so a selector that stops matching cannot pass for
// a clean codebase: a hover that swaps a colour instead of the B3 state layer, an <a> that holds only a
// glyph (DESIGN.md "A link that looks like a button" is IconLink), and a palette colour (F16).
async function messages(source: string, starts: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith(starts)).length;
}
const swaps = (src: string) => messages(src, "hover by a colour swap");
const glyphs = (src: string) => messages(src, "a glyph-only <a>");
const palette = (src: string) => messages(src, "a palette colour");
describe("a hover by a colour swap", () => {
// Assembled, so this file does not trip the very rule it probes.
@@ -55,3 +53,23 @@ describe("a glyph-only link", () => {
expect(await glyphs(src)).toBe(0);
});
});
// F16: DESIGN.md Tokens "only semantic tokens". A palette class is measured by nothing, so a gold that
// passes on dark ships at 2:1 on light. Black and white stay: they are the media scrims and their text.
describe("a palette colour", () => {
const c = (v: string) => `${v}-500`; // assembled, as above
it("is refused as a text, fill, edge, gradient stop or ring", async () => {
expect(await palette(`export const a = "${c("text-amber")}";\n`)).toBe(1);
expect(await palette(`export const b = "x ${c("bg-red")}/90";\n`)).toBe(1);
expect(await palette("export const d = `${1} " + c("from-slate") + "`;\n")).toBe(1);
expect(await palette(`export const e = "hover:${c("ring-neutral")}";\n`)).toBe(1);
});
it("leaves the tokens, black and white alone", async () => {
expect(
await palette(
`export const f = "text-rating bg-live text-live-fg bg-black/80 text-white/75";\n`,
),
).toBe(0);
expect(await palette(`export const g = "bg-accent text-danger border-border";\n`)).toBe(0);
});
});
@@ -1,17 +1,14 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/input-primitive` (eslint.config.js): every text field is ui/Input, Textarea or InnerInput and
// every checkbox / slider / colour well a NativeControls primitive (docs/DESIGN.md A4, A6; E28 S3c).
// ~100 hand-styled boxes were swept; this pins that the rule keeping a new one out fires, and where it
// stands down.
const RULE = "local/input-primitive";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
@@ -1,16 +1,13 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/jsx-comment-text` (eslint.config.js): a `//` inside JSX children is rendered as text. One
// reached the Playlists page past tsc, eslint and every test — only a visual check caught it. A rule
// that matches nothing looks exactly like a clean codebase, so this asserts it fires.
const RULE = "local/jsx-comment-text";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
+16
View File
@@ -0,0 +1,16 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { beforeAll } from "vitest";
// The one ESLint the lint-rule contract tests probe with (`*.contract.test.ts` that pin a selector in
// eslint.config.js). Its first lint loads the whole config and every plugin: ~1.5 s warm, measured at
// 8 s cold, past vitest's 5 s per test, so the first probe of a file timed out once (E28 audit). The
// hook below pays that cost once per test file, under its own budget; importing this registers it.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
export const eslint = new ESLint({ cwd: FRONTEND });
beforeAll(async () => {
await eslint.lintText("", { filePath: "src/components/__probe.tsx" });
}, 60_000);
@@ -1,17 +1,14 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/literal-a11y-text` (eslint.config.js): what a screen reader or a tooltip says is user-facing
// text, so it goes through t() like the rest. Five English `aria-label`s and a whole English panel
// survived every lane before it. A rule that matches nothing looks exactly like a clean codebase, so
// this asserts it fires.
const RULE = "local/literal-a11y-text";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
@@ -1,17 +1,14 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// E22 S4 put "every request is logged" into eslint.config.js, not prose: `fetch`, `XMLHttpRequest`
// and `WebSocket` each belong to ONE module that logs them, and `console` to none. A restriction
// that matches nothing looks exactly like a clean codebase, so this asserts the rules FIRE — and
// that each owner is exempt from its own global only.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
const RULES = new Set(["no-restricted-globals", "no-restricted-properties", "no-console"]);
async function flagged(source: string, filePath: string): Promise<string[]> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? [])
.filter((m) => m.ruleId && RULES.has(m.ruleId))
+2 -6
View File
@@ -1,14 +1,11 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// The px-frozen BOX rule in eslint.config.js (beside the older `text-[Npx]` one). A `no-restricted-
// syntax` selector that matches nothing looks exactly like a clean codebase, so pin that it fires.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function refused(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith("px-frozen box size")).length;
}
@@ -26,7 +23,6 @@ describe("a px-frozen box utility", () => {
it("is refused with a fractional size, for a box and for a font", async () => {
const px = (u: string, n: number) => `${u}-[${n}px]`;
expect(await refused(`export const e = "${px("w", 12.5)}";\n`)).toBe(1);
const eslint = new ESLint({ cwd: FRONTEND });
const [font] = await eslint.lintText(`export const f = "${px("text", 9.5)}";\n`, {
filePath: "src/components/__probe.tsx",
});
@@ -1,16 +1,13 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/sortable-a11y` (eslint.config.js): every sortable list speaks the user's language to a
// screen reader, through ui/sortableA11y. Three of four lists had shipped dnd-kit's English
// defaults. A rule that matches nothing looks exactly like a clean codebase, so this asserts it fires.
const RULE = "local/sortable-a11y";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
@@ -1,16 +1,13 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/surface-role` (eslint.config.js): a glass surface is a role from ui/surfaces.ts, which brings
// its radius (docs/DESIGN.md B5; E28 S3d). ~140 sites paired three tiers with five radii; this pins
// that the rule keeping a new one out fires, and where it stands down.
const RULE = "local/surface-role";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
+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([]);
});
+2 -5
View File
@@ -1,8 +1,7 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// E10 S1 declared `components/ui/` a platform layer and put the rule that makes it one — no feature
// components, no domain API slices — into eslint.config.js rather than prose. This asserts the rule
// actually FIRES, because a `no-restricted-imports` pattern that matches nothing is indistinguishable
@@ -18,10 +17,8 @@ const AS_UI = "src/components/ui/__boundary_probe.tsx";
// fileURLToPath, not URL.pathname: on Windows the latter yields "/D:/…", which eslint cannot resolve
// a config from. Anchored to this file rather than process.cwd() so the test holds wherever it is run.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function restrictedIn(source: string, filePath: string): Promise<string[]> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === BOUNDARY).map((m) => m.message);
}
+28 -4
View File
@@ -1,15 +1,14 @@
import { fileURLToPath } from "node:url";
import { readFileSync } from "node:fs";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// The layer-scale rule in eslint.config.js (DESIGN.md Tokens: "No ad-hoc z-index"). A numeric `z-10`
// happened to equal `base` and passed every review; a `no-restricted-syntax` selector that matches
// nothing looks exactly like a clean codebase, so pin that it fires.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function refused(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith("z-index off the layer scale"))
.length;
@@ -32,3 +31,28 @@ describe("a z-index off the layer scale", () => {
expect(await refused(`export const e = "${z("0")} md:${z("auto")}";\n`)).toBe(0);
});
});
// The Plex player's notices (the track-switch OSD, the subtitle notice) sat on `menu` while its settings
// and tracks popovers had no level: a notice drew over the open menu (E28 audit, seen live). A menu is
// above a notice; the info overlay (`menu`, later in the tree) still covers both.
describe("the Plex player's layers", () => {
const src = ["PlexPlayer.tsx", "PlexPlayerSettings.tsx"]
.map((f) => readFileSync(new URL(`../components/${f}`, import.meta.url), "utf8"))
.join("\n");
const classes = (marker: RegExp) =>
[...src.matchAll(/className="([^"]*)"/g)].map((m) => m[1]!).filter((c) => marker.test(c));
it("puts every popover on the menu level", () => {
const popovers = classes(
/\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/,
);
expect(popovers).toHaveLength(2);
for (const c of popovers) expect(c).toMatch(/\bz-menu\b/);
});
it("keeps the notices a level below", () => {
const notices = classes(/pointer-events-none absolute inset-x-0 bottom-(24|36)\b/);
expect(notices).toHaveLength(2);
for (const c of notices) expect(c).toMatch(/\bz-base\b/);
});
});