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 ## Tokens
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger - **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 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. 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 [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 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] 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` - **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 against the two rules above (text 4.5:1, a control edge 3:1, the status and rating colours as text, the accent's
gap). A scheme or a retuned colour that breaks them does not pass the gate. 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 - **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. 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 - **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 | 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 | 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 | 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 | 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 | | 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 = 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."; "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( export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { 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: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE }, { selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `TemplateElement[value.raw=${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) && ( {(maximized || isFullscreen) && (
<div <div
onClick={(e) => e.stopPropagation()} 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 && ( {hasQueue && (
<> <>
+27 -9
View File
@@ -90,6 +90,7 @@ function RatingsRow({
tmdbRating, tmdbRating,
tmdbVotes, tmdbVotes,
onFilterRating, onFilterRating,
onDark,
}: { }: {
imdbRating?: number | null; imdbRating?: number | null;
imdbUrl?: string | null; imdbUrl?: string | null;
@@ -97,11 +98,16 @@ function RatingsRow({
tmdbRating?: number | null; tmdbRating?: number | null;
tmdbVotes?: number | null; tmdbVotes?: number | null;
onFilterRating?: (score: number) => void; 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 { t } = useTranslation();
const primary = imdbRating ?? audience ?? null; // the filterable score const primary = imdbRating ?? audience ?? null; // the filterable score
const chipCls = // The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1.
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500"; 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; const has = (x?: number | null): x is number => x != null;
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null; if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
return ( return (
@@ -113,7 +119,7 @@ function RatingsRow({
filterable={imdbRating === primary} filterable={imdbRating === primary}
onFilterRating={onFilterRating} onFilterRating={onFilterRating}
/> />
<span className="text-2xs font-normal opacity-70">IMDb</span> <span className="text-2xs font-normal">IMDb</span>
</span> </span>
)} )}
{/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */} {/* 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" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
label={t("plex.info.openImdb")} label={t("plex.info.openImdb")}
variant={onDark ? "overlay" : "ghost"}
className="-ml-1.5" className="-ml-1.5"
> >
<ExternalLink /> <ExternalLink />
@@ -131,7 +138,7 @@ function RatingsRow({
{has(tmdbRating) && ( {has(tmdbRating) && (
<span className={chipCls}> <span className={chipCls}>
<Score score={tmdbRating} filterable={false} /> <Score score={tmdbRating} filterable={false} />
<span className="text-2xs font-normal opacity-70"> <span className="text-2xs font-normal">
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""} TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
</span> </span>
</span> </span>
@@ -139,7 +146,7 @@ function RatingsRow({
{has(audience) && imdbRating !== audience && ( {has(audience) && imdbRating !== audience && (
<span className={chipCls}> <span className={chipCls}>
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} /> <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> </span>
)} )}
</div> </div>
@@ -322,7 +329,7 @@ function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: bo
.join(" · "); .join(" · ");
return ( return (
<Badge onDark={overlay} title={t(`plex.playable.${cls}`)}> <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> <span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
{sub && <span className="opacity-70">{sub}</span>} {sub && <span className="opacity-70">{sub}</span>}
</Badge> </Badge>
@@ -568,6 +575,7 @@ export default function PlexInfo({
tmdbRating={detail.tmdb?.rating} tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count} tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined} onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
onDark={overlay}
/> />
</div> </div>
@@ -700,7 +708,9 @@ export default function PlexInfo({
{c.thumb ? ( {c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" /> <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)} {c.name.charAt(0)}
</div> </div>
)} )}
@@ -784,7 +794,11 @@ export default function PlexInfo({
disabled={!onPlayItem} disabled={!onPlayItem}
className="group/col w-28 shrink-0" 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 <img
src={m.thumb} src={m.thumb}
alt="" alt=""
@@ -818,7 +832,11 @@ export default function PlexInfo({
> >
{m.title} {m.title}
</div> </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> </Pressable>
))} ))}
</Carousel> </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 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, 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 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 <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" aria-live="polite"
> >
{selectedSub && subNotice && ( {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 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. */} round 1). Toaster.tsx carries the same rule and the same shape. */}
<div <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" aria-live="polite"
> >
{osd && ( {osd && (
@@ -1499,7 +1500,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
style={tracksFade.style} style={tracksFade.style}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onWheel={(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 && ( {detail.audio_streams.length > 1 && (
<> <>
@@ -1641,7 +1642,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}} }}
> >
<div <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()} onClick={(e) => e.stopPropagation()}
> >
<Suspense fallback={null}> <Suspense fallback={null}>
@@ -42,7 +42,7 @@ export function PlexPlayerSettings(props: {
ref={menuRef} ref={menuRef}
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
onClick={(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"> <div className="mb-1.5 flex gap-1">
{settingsTabs.map((tab) => ( {settingsTabs.map((tab) => (
+1 -1
View File
@@ -67,7 +67,7 @@ export function UpNextCard({
role="region" role="region"
aria-label={t("plex.player.upNext.title")} aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()} 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"> <div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && ( {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. // "live" dot and a "stream" dot would be the same dot.
const status = const status =
video.live_status === "live" video.live_status === "live"
? // fixed live-red, not the status token — "live" is a broadcast indicator, not an error ? // the broadcast red, not --danger: "live" is not an error
{ Icon: Radio, cls: "text-red-500", label: t("card.live") } { Icon: Radio, cls: "text-live", label: t("card.live") }
: video.live_status === "upcoming" : video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") } ? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live" : video.live_status === "was_live"
@@ -253,7 +253,7 @@ function Thumb({
) : video.live_status === "live" || video.live_status === "upcoming" ? ( ) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span <span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${ 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}`)} {t(`card.${video.live_status}`)}
+1 -1
View File
@@ -113,7 +113,7 @@ export function playlistColumns({
<span <span
className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${ className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live" 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" : "bg-card border border-border text-muted"
}`} }`}
> >
+2 -2
View File
@@ -100,7 +100,7 @@ export function PlexPosterCard({
<span <span
role="img" role="img"
aria-label={t(`plex.playable.${cls}`)} 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}`)} title={t(`plex.playable.${cls}`)}
/> />
); );
@@ -164,7 +164,7 @@ export function PlexPosterCard({
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span> <span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
)} )}
{card.rating != null && ( {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" /> <Star className="w-3 h-3 fill-current" />
{card.rating.toFixed(1)} {card.rating.toFixed(1)}
</span> </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"}`} 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 */} {/* the rating gold — its own token, not --warn: a score is not a warning */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" /> · <Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
{score.toFixed(1)} {score.toFixed(1)}
</Pressable> </Pressable>
); );
+2 -2
View File
@@ -108,7 +108,7 @@ function C3() {
title="The players on touch" 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." 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"> <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. */} {/* The visible track stays thin; the dashed box is the 44-tall hit area around it. */}
<div <div
@@ -233,7 +233,7 @@ const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function PlayerRow() { function PlayerRow() {
return ( 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. */} {/* 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"> <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"> <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-warn-fg: var(--warn-fg);
--color-success: var(--success); --color-success: var(--success);
--color-success-fg: var(--success-fg); --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; --font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@@ -988,6 +993,7 @@ html[data-theme="dark"] {
--success-fg: #052016; --success-fg: #052016;
--info: #60a5fa; --info: #60a5fa;
--info-fg: #08172e; --info-fg: #08172e;
--rating: #fbbf24;
} }
html[data-theme="light"] { html[data-theme="light"] {
/* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted /* 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; --success-fg: #ffffff;
--info: #2563eb; --info: #2563eb;
--info-fg: #ffffff; --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) */ /* 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 { 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 // `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 // 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. // DragHandle; this pins that the rule keeping a new one out actually fires, and where it stands down.
const RULE = "local/button-primitive"; 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> { 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 }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; 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 { 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; // 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 // 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. // 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> { async function refused(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" }); const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith("No native <select>")).length; 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. // which then lit up on hover as if a click would work.
describe("a hover gated on `enabled:` alone", () => { describe("a hover gated on `enabled:` alone", () => {
it("is refused; the aria-aware gate is not", async () => { it("is refused; the aria-aware gate is not", async () => {
const eslint = new ESLint({ cwd: FRONTEND });
const lint = async (cls: string) => const lint = async (cls: string) =>
( (
await eslint.lintText( 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 { 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 // `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 // 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. // the rule keeping a new one out actually fires.
const RULE = "local/focus-safe-disabled"; 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> { 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 }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; 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"; 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 import { eslint } from "./lintProbe.testkit";
// 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). // Three E28-audit rules in eslint.config.js, pinned so a selector that stops matching cannot pass for
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url)); // 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> { 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" }); const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith(starts)).length; return (result?.messages ?? []).filter((m) => m.message.startsWith(starts)).length;
} }
const swaps = (src: string) => messages(src, "hover by a colour swap"); const swaps = (src: string) => messages(src, "hover by a colour swap");
const glyphs = (src: string) => messages(src, "a glyph-only <a>"); 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", () => { describe("a hover by a colour swap", () => {
// Assembled, so this file does not trip the very rule it probes. // 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); 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 { 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 // `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). // 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 // ~100 hand-styled boxes were swept; this pins that the rule keeping a new one out fires, and where it
// stands down. // stands down.
const RULE = "local/input-primitive"; 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> { 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 }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; 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 { 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 // `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 // 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. // that matches nothing looks exactly like a clean codebase, so this asserts it fires.
const RULE = "local/jsx-comment-text"; const RULE = "local/jsx-comment-text";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string): Promise<number> { async function hits(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" }); const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; 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 { 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 // `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 // 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 // survived every lane before it. A rule that matches nothing looks exactly like a clean codebase, so
// this asserts it fires. // this asserts it fires.
const RULE = "local/literal-a11y-text"; const RULE = "local/literal-a11y-text";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string): Promise<number> { async function hits(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" }); const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; 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 { 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` // 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 // 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 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. // 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"]); const RULES = new Set(["no-restricted-globals", "no-restricted-properties", "no-console"]);
async function flagged(source: string, filePath: string): Promise<string[]> { async function flagged(source: string, filePath: string): Promise<string[]> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []) return (result?.messages ?? [])
.filter((m) => m.ruleId && RULES.has(m.ruleId)) .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 { 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- // 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. // 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> { async function refused(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" }); const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith("px-frozen box size")).length; 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 () => { it("is refused with a fractional size, for a box and for a font", async () => {
const px = (u: string, n: number) => `${u}-[${n}px]`; const px = (u: string, n: number) => `${u}-[${n}px]`;
expect(await refused(`export const e = "${px("w", 12.5)}";\n`)).toBe(1); 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`, { const [font] = await eslint.lintText(`export const f = "${px("text", 9.5)}";\n`, {
filePath: "src/components/__probe.tsx", 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 { 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 // `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 // 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. // defaults. A rule that matches nothing looks exactly like a clean codebase, so this asserts it fires.
const RULE = "local/sortable-a11y"; 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> { 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 }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; 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 { 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 // `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 // 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. // that the rule keeping a new one out fires, and where it stands down.
const RULE = "local/surface-role"; 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> { 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 }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; 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 status = (mode: Mode) => block(`html[data-theme="${mode}"]`);
const scheme = (id: string, mode: Mode) => block(`html[data-scheme="${id}"][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", () => { it("styles exactly the schemes the app offers", () => {
const inCss = new Set( const inCss = new Set(
@@ -94,8 +113,13 @@ describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
danger: hexContrast(s.danger!, bg), danger: hexContrast(s.danger!, bg),
warn: hexContrast(s.warn!, bg), warn: hexContrast(s.warn!, bg),
success: hexContrast(s.success!, 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([]); 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 { 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 // 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 // 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 // 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 // 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. // 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[]> { async function restrictedIn(source: string, filePath: string): Promise<string[]> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath }); const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === BOUNDARY).map((m) => m.message); 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 { 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` // 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 // 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. // 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> { async function refused(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" }); 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")) return (result?.messages ?? []).filter((m) => m.message.startsWith("z-index off the layer scale"))
.length; .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); 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/);
});
});