fix(player): what is chosen on the player chrome is marked amber, not written in the accent

A light theme's accent as text on the always-dark chrome was 2.84:1 (Slate). `--chosen` is the
accent unless a scope names its own; `media-marks` (was media-focus) points it, with the focus
ring, at the media amber. A chosen track stays white with an amber tick (MenuCheck reads
`text-chosen`); a chosen settings tab or toggle is white with an amber underline (MEDIA_CHOSEN).
The theme contract test holds the token, the tick and the player components free of accent text.
This commit is contained in:
2026-10-10 21:56:47 +02:00
parent e1d325b942
commit 8312c6d592
13 changed files with 85 additions and 49 deletions
+9 -6
View File
@@ -113,11 +113,14 @@ style guide.
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an 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 error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
(owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a (owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a
Plex marker) and the focus ring, and `--media-danger` an error, as a light theme's accent and danger are Plex marker), the focus ring and what is chosen, and `--media-danger` an error, as a light theme's
dark on the chrome: `media-focus` re-points `--focus` on the player (the Plex player, the YouTube stage) accent and danger are dark on the chrome: `media-marks` re-points `--focus` and `--chosen` on the
and on every surface painted `--media-chrome` (its menus, panels and bars), and a form there player (the Plex player, the YouTube stage) and on every surface painted `--media-chrome` (its menus,
(`media-scope`) re-points `--danger` too. `theme.contract.test.ts` measures the red on the chrome and panels and bars), and a form there (`media-scope`) re-points `--danger` too. A chosen item there is
holds every class string that paints it to name one of the two. Black and white are the media scrims and their text. Five schemes × dark white like the rest, marked by an amber tick (a track) or underline (a tab, `ui/mediaChrome`), never
the accent as text; a choice filled with the accent keeps it, its own text holds on it.
`theme.contract.test.ts` measures the red on the chrome and holds every class string that paints it
to name one of the two. Black and white are the media scrims and their text. Five schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
@@ -767,4 +770,4 @@ of a full-width panel and nothing on the right.
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays | | 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) | | 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture | | 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-focus` points the focus ring (`--focus`) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1 | | 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
+2 -2
View File
@@ -936,7 +936,7 @@ export default function PlayerModal({
ref={stageRef} ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls. // The wheel over the video is its volume, even inside a stacked card that scrolls.
data-wheel-volume="" data-wheel-volume=""
className={`player-stage media-focus relative aspect-video w-full bg-black overflow-hidden ${ className={`player-stage media-marks relative aspect-video w-full bg-black overflow-hidden ${
wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl" wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`} } ${maximized ? "player-stage--max" : ""}`}
> >
@@ -1009,7 +1009,7 @@ export default function PlayerModal({
{(maximized || isFullscreen) && !clipDocked && ( {(maximized || isFullscreen) && !clipDocked && (
<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-media-chrome/95 media-focus 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 media-marks px-3 text-sm text-white"
> >
{hasQueue && ( {hasQueue && (
<> <>
+10 -13
View File
@@ -113,12 +113,13 @@ const BACK_TO_PREV_S = 3;
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step). // inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
// The player's menus sit on the always-dark media chrome, under white text, in every theme. // The player's menus sit on the always-dark media chrome, under white text, in every theme.
const PLAYER_MENU = const PLAYER_MENU =
"rounded-xl border border-white/15 bg-media-chrome/95 media-focus text-sm text-white shadow-2xl"; "rounded-xl border border-white/15 bg-media-chrome/95 media-marks text-sm text-white shadow-2xl";
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10"; const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which // A track choice: white like the rest, the chosen one marked by the tick in the leading slot (DESIGN
// the highlight's white text cannot wash out the way it did the accent-coloured label. // "one of several"), which `media-marks` paints the media amber — a light theme's accent as the
// label's colour was dark on the chrome (Slate 2.84:1), and the highlight's white washed it out.
const TRACK_ROW = const TRACK_ROW =
"flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10"; "flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-white hover:bg-white/10";
const TAP_SKIP_S = 10; const TAP_SKIP_S = 10;
const DOUBLE_TAP_MS = 300; const DOUBLE_TAP_MS = 300;
@@ -1191,7 +1192,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
return createPortal( return createPortal(
<div <div
ref={wrapRef} ref={wrapRef}
className="media-focus fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none" className="media-marks fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
onPointerDown={(e) => { onPointerDown={(e) => {
pointerRef.current = e.pointerType; pointerRef.current = e.pointerType;
}} }}
@@ -1677,9 +1678,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={(audioOrd ?? 0) === a.ord} checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)} label={audioName(a)}
onSelect={() => changeAudio(a.ord)} onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${ className={TRACK_ROW}
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
> >
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} /> <MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span> <span className="truncate">{audioName(a)}</span>
@@ -1696,7 +1695,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={subOrd == null} checked={subOrd == null}
label={t("plex.player.subOff")} label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)} onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`} className={TRACK_ROW}
> >
<MenuCheck kind="one" on={subOrd == null} /> <MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span> <span className="truncate">{t("plex.player.subOff")}</span>
@@ -1709,9 +1708,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={subOrd === sub.ord} checked={subOrd === sub.ord}
label={subtitleName(sub)} label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)} onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${ className={TRACK_ROW}
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
> >
<MenuCheck kind="one" on={subOrd === sub.ord} /> <MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span> <span className="truncate">{subtitleName(sub)}</span>
@@ -1840,7 +1837,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}} }}
> >
<div <div
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 media-focus shadow-2xl" className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-media-chrome/95 media-marks shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Suspense fallback={null}> <Suspense fallback={null}>
@@ -7,6 +7,7 @@ import { Pressable } from "./ui/Pressable";
import { ColorInput, CheckRow } from "./ui/NativeControls"; import { ColorInput, CheckRow } from "./ui/NativeControls";
import Tabs from "./ui/Tabs"; import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel"; import { useTabPanel } from "./ui/tabPanel";
import { MEDIA_CHOSEN } from "./ui/mediaChrome";
type SettingsTab = "sync" | "playback" | "subtitle" | "clock"; type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
@@ -65,7 +66,7 @@ export function PlexPlayerSettings(props: {
aria-label={t("plex.player.settings")} aria-label={t("plex.player.settings")}
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-media-chrome/95 media-focus absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl" className="border border-white/15 bg-media-chrome/95 media-marks absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
> >
<Tabs <Tabs
tone="media" tone="media"
@@ -273,9 +274,7 @@ export function PlexPlayerSettings(props: {
aria-pressed={prefs.clockDateStyle === st} aria-pressed={prefs.clockDateStyle === st}
onClick={() => patchPrefs({ clockDateStyle: st })} onClick={() => patchPrefs({ clockDateStyle: st })}
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${ className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st prefs.clockDateStyle === st ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
? "bg-white/15 text-accent"
: "text-white/70 hover:bg-white/10"
}`} }`}
> >
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date} {clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
+1 -1
View File
@@ -72,7 +72,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-media-chrome/95 media-focus 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 media-marks 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 && (
@@ -104,7 +104,7 @@ export const ROVING_HIGHLIGHT =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60"; "data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
const HIGHLIGHT_SELECTED = const HIGHLIGHT_SELECTED =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80"; "data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
// On the always-dark media chrome (a player's menus, `bg-media-chrome media-focus` under white text) the theme's // On the always-dark media chrome (a player's menus, `bg-media-chrome media-marks` under white text) the theme's
// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead. // `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
const MEDIA_HIGHLIGHT = const MEDIA_HIGHLIGHT =
"data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40"; "data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40";
+1 -1
View File
@@ -13,7 +13,7 @@ export function MenuCheck({ kind, on }: { kind: "many" | "one"; on: boolean | "m
return ( return (
<Check <Check
aria-hidden="true" aria-hidden="true"
className={`size-4 shrink-0 ${on === true ? "text-accent" : "opacity-0"}`} className={`size-4 shrink-0 ${on === true ? "text-chosen" : "opacity-0"}`}
/> />
); );
return ( return (
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here ## What lives here
| area | components | | area | components |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) | | data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | | page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` | | state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c) ## The size contract (E10 S1c)
@@ -61,4 +61,13 @@ describe("Tabs", () => {
fireEvent.click(tab("Beta")); fireEvent.click(tab("Beta"));
expect(screen.getByRole("tabpanel", { name: "Beta" })).toBe(panel); expect(screen.getByRole("tabpanel", { name: "Beta" })).toBe(panel);
}); });
it("on the media chrome marks the chosen tab in white with the chosen mark, not the accent's text", () => {
// A light theme's accent is dark on the always-dark chrome (Slate 2.84:1 as text).
render(<Tabs tabs={TABS} active="a" onChange={() => {}} label="Pick" tone="media" />);
const on = tab("Alpha").className;
expect(on).not.toMatch(/\btext-accent\b/);
expect(on).toMatch(/\btext-white\b/);
expect(on).toMatch(/--color-chosen/);
});
}); });
+2 -1
View File
@@ -2,6 +2,7 @@ import { Fragment, useId, useRef, type KeyboardEvent } from "react";
import { panelId, tabId } from "./tabPanel"; import { panelId, tabId } from "./tabPanel";
import { type LucideIcon } from "lucide-react"; import { type LucideIcon } from "lucide-react";
import CountBadge from "./CountBadge"; import CountBadge from "./CountBadge";
import { MEDIA_CHOSEN } from "./mediaChrome";
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings // Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives // sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
@@ -125,7 +126,7 @@ export default function Tabs({
className={ className={
tone === "media" tone === "media"
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${ ? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10" on ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
}` }`
: `pill touch-target items-center rounded-lg px-3 text-sm ${ : `pill touch-target items-center rounded-lg px-3 text-sm ${
vertical vertical
@@ -0,0 +1,6 @@
// The player's always-dark chrome, which looks the same in every theme because the picture under it
// does (docs/DESIGN.md, "Marks on the player chrome").
/** A choice chosen on the chrome (a settings tab, a date style): white on a veil, underlined in the
* chosen mark — `media-marks` points it at the media amber, as a light theme's accent is dark there. */
export const MEDIA_CHOSEN = "bg-white/15 text-white shadow-[inset_0_-2px_0_var(--color-chosen)]";
+13 -7
View File
@@ -33,6 +33,9 @@
--color-accent: var(--accent); --color-accent: var(--accent);
/* A focus ring's colour: the accent, unless a scope over another ground names its own. */ /* A focus ring's colour: the accent, unless a scope over another ground names its own. */
--color-focus: var(--focus, var(--accent)); --color-focus: var(--focus, var(--accent));
/* What marks a choice standing on its ground (a chosen row's tick, a chosen tab's underline): the
accent, unless a scope over another ground names its own. */
--color-chosen: var(--chosen, var(--accent));
--color-accent-fg: var(--accent-fg); --color-accent-fg: var(--accent-fg);
--color-info: var(--info); --color-info: var(--info);
--color-info-fg: var(--info-fg); --color-info-fg: var(--info-fg);
@@ -145,18 +148,20 @@
border-color: var(--edge-strong); border-color: var(--edge-strong);
} }
/* The focus ring on the always-dark player chrome (a player, its menus and panels): the media amber, /* The marks on the always-dark player chrome (a player, its menus and panels) — the focus ring and
as a light theme's accent is dark (under 3:1 on the chrome). Every class string that paints what is chosen — are the media amber, as a light theme's accent is dark there (Slate 2.84:1).
bg-media-chrome names it or media-scope (theme.contract.test.ts). */ Every class string that paints bg-media-chrome names it or media-scope (theme.contract.test.ts). */
@utility media-focus { @utility media-marks {
--focus: var(--media-mark); --focus: var(--media-mark);
--chosen: var(--media-mark);
} }
/* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field /* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field
primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text
never lands on the dark bar. The accent stays the theme's for what is chosen; what must be seen never lands on the dark bar. The accent stays the theme's for a choice FILLED with it (its own
there is not left to it: a focus ring is the media amber and an error the media red, as a light text on it holds); a mark standing on the chrome itself is not left to it: a focus ring and a
theme's accent and danger are dark (under 3:1 on the chrome). */ chosen mark are the media amber and an error the media red, as a light theme's accent and danger
are dark (under 3:1 on the chrome). */
@utility media-scope { @utility media-scope {
--fg: #ffffff; --fg: #ffffff;
--muted: rgb(255 255 255 / 0.62); --muted: rgb(255 255 255 / 0.62);
@@ -168,6 +173,7 @@
--card: rgb(255 255 255 / 0.08); --card: rgb(255 255 255 / 0.08);
--danger: var(--media-danger); --danger: var(--media-danger);
--focus: var(--media-mark); --focus: var(--media-mark);
--chosen: var(--media-mark);
color: var(--fg); color: var(--fg);
} }
+21 -6
View File
@@ -81,18 +81,33 @@ describe("the media colours", () => {
expect(field).not.toMatch(/\bfocus-within:(border|ring)-accent\b/); expect(field).not.toMatch(/\bfocus-within:(border|ring)-accent\b/);
}); });
it("every surface painted the player chrome names its focus colour", () => { it("every surface painted the player chrome names its marks: the focus ring and what is chosen", () => {
const at = css.indexOf("@utility media-focus {"); const block = (name: string) => {
expect(css.slice(at, css.indexOf("\n}", at))).toMatch(/--focus:\s*var\(--media-mark\);/); const at = css.indexOf(name);
// A class string that paints the chrome carries the media focus (media-scope re-points it too): return css.slice(at, css.indexOf("\n}", at));
// the theme's accent ring fell under 3:1 on it in a light scheme. };
expect(block("@utility media-marks {")).toMatch(/--focus:\s*var\(--media-mark\);/);
expect(block("@utility media-marks {")).toMatch(/--chosen:\s*var\(--media-mark\);/);
expect(block("@utility media-scope {")).toMatch(/--chosen:\s*var\(--media-mark\);/);
expect(block("@theme inline {")).toMatch(/--color-chosen: var\(--chosen, var\(--accent\)\);/);
// The tick of a chosen row reads the chosen colour, so a scope can name its own.
const check = readFileSync(new URL("../components/ui/MenuCheck.tsx", import.meta.url), "utf8");
expect(check).toMatch(/\btext-chosen\b/);
// The player's own components are chrome throughout: nothing chosen there is the accent's text
// (a light theme's accent is dark on it — Slate 2.84:1).
for (const f of ["PlexPlayer.tsx", "PlexPlayerSettings.tsx", "UpNextCard.tsx"]) {
const src = readFileSync(new URL(`../components/${f}`, import.meta.url), "utf8");
expect(src, f).not.toMatch(/\btext-accent\b/);
}
// A class string that paints the chrome carries the media marks (media-scope re-points them
// too): the theme's accent ring fell under 3:1 on it in a light scheme.
const root = new URL("../", import.meta.url); const root = new URL("../", import.meta.url);
const offenders: string[] = []; const offenders: string[] = [];
for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) { for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) {
if (!/\.tsx?$/.test(f) || /\.test\.tsx?$/.test(f)) continue; if (!/\.tsx?$/.test(f) || /\.test\.tsx?$/.test(f)) continue;
const src = readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8"); const src = readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8");
for (const m of src.matchAll(/["'`]([^"'`]*\bbg-media-chrome\b[^"'`]*)["'`]/g)) for (const m of src.matchAll(/["'`]([^"'`]*\bbg-media-chrome\b[^"'`]*)["'`]/g))
if (!/\bmedia-(scope|focus)\b/.test(m[1]!)) offenders.push(`${f}: ${m[1]}`); if (!/\bmedia-(scope|marks)\b/.test(m[1]!)) offenders.push(`${f}: ${m[1]}`);
} }
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });