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
+9 -3
View File
@@ -69,7 +69,12 @@ style guide.
## Tokens
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
--warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark
--warn --success --info --border`, each with a `-fg` partner where text sits on it), and no palette class
(`text-amber-500`: lint-refused). A score's gold is `--rating` (theme-scoped, 4.5:1 on the page and on its
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
(owner decision F16). Black and white are the media scrims and their text. Four schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its
place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
@@ -81,8 +86,8 @@ style guide.
the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3]
- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css`
against the two rules above (text 4.5:1, a control edge 3:1, the status colours as text, the accent's hue
gap). A scheme or a retuned colour that breaks them does not pass the gate.
against the two rules above (text 4.5:1, a control edge 3:1, the status and rating colours as text, the accent's
hue gap), and the media colours against what they sit on. A scheme or a retuned colour that breaks them does not pass the gate.
- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was
retired (E28), because free tuning is exactly the drift these rules exist to stop.
- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a
@@ -361,5 +366,6 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+10
View File
@@ -364,6 +364,14 @@ const HOVER_SWAP =
const HOVER_SWAP_MESSAGE =
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
// A palette colour (DESIGN.md Tokens "only semantic tokens", audit F16): nothing measures `text-amber-500`,
// so a gold that reads on dark ships at 2:1 on light. Black and white are not matched: they are the
// media scrims and the text on them, the same in every theme.
const PALETTE =
"/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/";
const PALETTE_MESSAGE =
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
{
@@ -481,6 +489,8 @@ export default tseslint.config(
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
],
},
},
+1 -1
View File
@@ -653,7 +653,7 @@ export default function PlayerModal({
{(maximized || isFullscreen) && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-neutral-950/95 px-3 text-sm text-white"
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
>
{hasQueue && (
<>
+27 -9
View File
@@ -90,6 +90,7 @@ function RatingsRow({
tmdbRating,
tmdbVotes,
onFilterRating,
onDark,
}: {
imdbRating?: number | null;
imdbUrl?: string | null;
@@ -97,11 +98,16 @@ function RatingsRow({
tmdbRating?: number | null;
tmdbVotes?: number | null;
onFilterRating?: (score: number) => void;
/** Over the always-dark player: the page's `--rating` is tuned for the theme's page, so a light
* theme's dark gold would sit on the player's black (2.6:1, measured). */
onDark?: boolean;
}) {
const { t } = useTranslation();
const primary = imdbRating ?? audience ?? null; // the filterable score
const chipCls =
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
// The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1.
const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
onDark ? "bg-rating-media/15 text-rating-media" : "bg-rating/15 text-rating"
}`;
const has = (x?: number | null): x is number => x != null;
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
return (
@@ -113,7 +119,7 @@ function RatingsRow({
filterable={imdbRating === primary}
onFilterRating={onFilterRating}
/>
<span className="text-2xs font-normal opacity-70">IMDb</span>
<span className="text-2xs font-normal">IMDb</span>
</span>
)}
{/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */}
@@ -123,6 +129,7 @@ function RatingsRow({
target="_blank"
rel="noopener noreferrer"
label={t("plex.info.openImdb")}
variant={onDark ? "overlay" : "ghost"}
className="-ml-1.5"
>
<ExternalLink />
@@ -131,7 +138,7 @@ function RatingsRow({
{has(tmdbRating) && (
<span className={chipCls}>
<Score score={tmdbRating} filterable={false} />
<span className="text-2xs font-normal opacity-70">
<span className="text-2xs font-normal">
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
</span>
</span>
@@ -139,7 +146,7 @@ function RatingsRow({
{has(audience) && imdbRating !== audience && (
<span className={chipCls}>
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
<span className="text-2xs font-normal">{t("plex.info.audience")}</span>
</span>
)}
</div>
@@ -322,7 +329,7 @@ function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: bo
.join(" · ");
return (
<Badge onDark={overlay} title={t(`plex.playable.${cls}`)}>
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-muted"}`} />
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
{sub && <span className="opacity-70">{sub}</span>}
</Badge>
@@ -568,6 +575,7 @@ export default function PlexInfo({
tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
onDark={overlay}
/>
</div>
@@ -700,7 +708,9 @@ export default function PlexInfo({
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center text-lg opacity-40">
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
)}
@@ -784,7 +794,11 @@ export default function PlexInfo({
disabled={!onPlayItem}
className="group/col w-28 shrink-0"
>
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-surface">
<div
className={`relative aspect-2/3 overflow-hidden rounded-lg border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
}`}
>
<img
src={m.thumb}
alt=""
@@ -818,7 +832,11 @@ export default function PlexInfo({
>
{m.title}
</div>
{m.year ? <div className="text-2xs text-muted">{m.year}</div> : null}
{m.year ? (
<div className={`text-2xs ${overlay ? "text-white/55" : "text-muted"}`}>
{m.year}
</div>
) : null}
</Pressable>
))}
</Carousel>
+6 -5
View File
@@ -1183,9 +1183,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
above the track-switch OSD so the two never overlap. */}
above the track-switch OSD so the two never overlap. Both notices are `z-base`, under the
settings and tracks popovers (`z-menu`): an open menu is never drawn over (E28 audit). */}
<div
className="pointer-events-none absolute inset-x-0 bottom-36 z-menu flex justify-center px-4"
className="pointer-events-none absolute inset-x-0 bottom-36 z-base flex justify-center px-4"
aria-live="polite"
>
{selectedSub && subNotice && (
@@ -1211,7 +1212,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
conditioning the wrapper re-created it for every switch, not just the first (E10 review
round 1). Toaster.tsx carries the same rule and the same shape. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-24 z-menu flex justify-center px-4"
className="pointer-events-none absolute inset-x-0 bottom-24 z-base flex justify-center px-4"
aria-live="polite"
>
{osd && (
@@ -1499,7 +1500,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
style={tracksFade.style}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
>
{detail.audio_streams.length > 1 && (
<>
@@ -1641,7 +1642,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}}
>
<div
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-neutral-900/95 shadow-2xl"
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Suspense fallback={null}>
@@ -42,7 +42,7 @@ export function PlexPlayerSettings(props: {
ref={menuRef}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
>
<div className="mb-1.5 flex gap-1">
{settingsTabs.map((tab) => (
+1 -1
View File
@@ -67,7 +67,7 @@ export function UpNextCard({
role="region"
aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-neutral-900/95 p-3.5 text-white shadow-2xl"
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 p-3.5 text-white shadow-2xl"
>
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
+3 -3
View File
@@ -158,8 +158,8 @@ function RowMetaCells({ video }: { video: Video }) {
// "live" dot and a "stream" dot would be the same dot.
const status =
video.live_status === "live"
? // fixed live-red, not the status token — "live" is a broadcast indicator, not an error
{ Icon: Radio, cls: "text-red-500", label: t("card.live") }
? // the broadcast red, not --danger: "live" is not an error
{ Icon: Radio, cls: "text-live", label: t("card.live") }
: video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live"
@@ -253,7 +253,7 @@ function Thumb({
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live" ? "bg-red-500/90 text-white" : "bg-black/80 text-white"
video.live_status === "live" ? "bg-live text-live-fg" : "bg-black/80 text-white"
}`}
>
{t(`card.${video.live_status}`)}
+1 -1
View File
@@ -113,7 +113,7 @@ export function playlistColumns({
<span
className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live"
? "bg-red-500/90 text-white" /* fixed live-red, not the status token */
? "bg-live text-live-fg" /* the broadcast red, not --danger */
: "bg-card border border-border text-muted"
}`}
>
+2 -2
View File
@@ -100,7 +100,7 @@ export function PlexPosterCard({
<span
role="img"
aria-label={t(`plex.playable.${cls}`)}
className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-muted"}`}
title={t(`plex.playable.${cls}`)}
/>
);
@@ -164,7 +164,7 @@ export function PlexPosterCard({
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
)}
{card.rating != null && (
<span className="inline-flex items-center gap-0.5 text-amber-300">
<span className="inline-flex items-center gap-0.5 text-rating-media">
<Star className="w-3 h-3 fill-current" />
{card.rating.toFixed(1)}
</span>
+2 -2
View File
@@ -441,8 +441,8 @@ export function PlexShowView({
}
className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{/* the rating gold — its own token, not --warn: a score is not a warning */}
· <Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
{score.toFixed(1)}
</Pressable>
);
+2 -2
View File
@@ -108,7 +108,7 @@ function C3() {
title="The players on touch"
rule="Plex: a tap on the video only shows or hides the controls; play/pause is the button (the mouse keeps click-to-pause). The hide timer starts with playback, not with the first touch. The seek track stays thin inside a 44px hit area with touch-action: none, its head always visible. Buttons 44. The YouTube modal's chrome uses the Button rungs (44 on touch), its side arrows stay inside the screen, and its description opens from an ⓘ button, because the hover popover never opens on a tap."
>
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-white/25 to-black text-white">
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-linear-to-t from-black/80 to-transparent px-3 pb-2 pt-8">
{/* The visible track stays thin; the dashed box is the 44-tall hit area around it. */}
<div
@@ -233,7 +233,7 @@ const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function PlayerRow() {
return (
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-white/25 to-black text-white">
{/* Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch. */}
<div className="absolute inset-x-0 top-0 bottom-28 flex justify-between text-2xs text-white/70">
<span className="flex w-1/3 items-start justify-center border-r border-dashed border-white/25 pt-6">
+19
View File
@@ -40,6 +40,11 @@
--color-warn-fg: var(--warn-fg);
--color-success: var(--success);
--color-success-fg: var(--success-fg);
--color-rating: var(--rating);
--color-rating-media: var(--rating-media);
--color-live: var(--live);
--color-live-fg: var(--live-fg);
--color-media-chrome: var(--media-chrome);
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@@ -988,6 +993,7 @@ html[data-theme="dark"] {
--success-fg: #052016;
--info: #60a5fa;
--info-fg: #08172e;
--rating: #fbbf24;
}
html[data-theme="light"] {
/* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted
@@ -1000,6 +1006,19 @@ html[data-theme="light"] {
--success-fg: #ffffff;
--info: #2563eb;
--info-fg: #ffffff;
--rating: #854d0e; /* yellow-800: yellow-700 fell to 3.7:1 on its own 15% chip */
}
/* ===== Media colours (F16) =====
Theme-INDEPENDENT: they sit on a picture (a thumbnail, a poster's scrim, the player), and the
picture does not change with the theme. `--rating` above is the page's copy of the rating gold, so it
is theme-scoped like a status; it is not a status (a score is not a warning), hence its own token.
theme.contract.test.ts measures all of them. */
:root {
--live: #dc2626; /* a broadcast's red, under --live-fg; ≥ 3:1 as a glyph on every page */
--live-fg: #ffffff;
--rating-media: #fcd34d; /* a score on a poster's black scrim */
--media-chrome: #171717; /* the always-dark player's menus and bars, under white text */
}
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@@ -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/);
});
});