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
+2 -2
View File
@@ -936,7 +936,7 @@ export default function PlayerModal({
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
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"
} ${maximized ? "player-stage--max" : ""}`}
>
@@ -1009,7 +1009,7 @@ export default function PlayerModal({
{(maximized || isFullscreen) && !clipDocked && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-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 && (
<>
+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).
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
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";
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
// the highlight's white text cannot wash out the way it did the accent-coloured label.
// A track choice: white like the rest, the chosen one marked by the tick in the leading slot (DESIGN
// "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 =
"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 DOUBLE_TAP_MS = 300;
@@ -1191,7 +1192,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
return createPortal(
<div
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) => {
pointerRef.current = e.pointerType;
}}
@@ -1677,9 +1678,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
className={TRACK_ROW}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span>
@@ -1696,7 +1695,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
className={TRACK_ROW}
>
<MenuCheck kind="one" on={subOrd == null} />
<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}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
className={TRACK_ROW}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span>
@@ -1840,7 +1837,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}}
>
<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()}
>
<Suspense fallback={null}>
@@ -7,6 +7,7 @@ import { Pressable } from "./ui/Pressable";
import { ColorInput, CheckRow } from "./ui/NativeControls";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { MEDIA_CHOSEN } from "./ui/mediaChrome";
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
@@ -65,7 +66,7 @@ export function PlexPlayerSettings(props: {
aria-label={t("plex.player.settings")}
onWheel={(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
tone="media"
@@ -273,9 +274,7 @@ export function PlexPlayerSettings(props: {
aria-pressed={prefs.clockDateStyle === st}
onClick={() => patchPrefs({ clockDateStyle: st })}
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st
? "bg-white/15 text-accent"
: "text-white/70 hover:bg-white/10"
prefs.clockDateStyle === st ? MEDIA_CHOSEN : "text-white/70 hover:bg-white/10"
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
+1 -1
View File
@@ -72,7 +72,7 @@ export function UpNextCard({
role="region"
aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-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">
{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";
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";
// 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.
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";
+1 -1
View File
@@ -13,7 +13,7 @@ export function MenuCheck({ kind, on }: { kind: "many" | "one"; on: boolean | "m
return (
<Check
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 (
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| 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` |
| 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` |
| 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` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| 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` |
| 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) |
| 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` |
## The size contract (E10 S1c)
@@ -61,4 +61,13 @@ describe("Tabs", () => {
fireEvent.click(tab("Beta"));
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 { type LucideIcon } from "lucide-react";
import CountBadge from "./CountBadge";
import { MEDIA_CHOSEN } from "./mediaChrome";
// 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
@@ -125,7 +126,7 @@ export default function Tabs({
className={
tone === "media"
? `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 ${
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)]";