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:
+9
-3
@@ -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 |
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}`)}
|
||||||
|
|||||||
@@ -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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user