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