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:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user