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:
+9
-6
@@ -113,11 +113,14 @@ style guide.
|
||||
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). 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
|
||||
dark on the chrome: `media-focus` re-points `--focus` on the player (the Plex player, the YouTube stage)
|
||||
and on every surface painted `--media-chrome` (its menus, panels and bars), and a form there
|
||||
(`media-scope`) re-points `--danger` too. `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
|
||||
Plex marker), the focus ring and what is chosen, and `--media-danger` an error, as a light theme's
|
||||
accent and danger are dark on the chrome: `media-marks` re-points `--focus` and `--chosen` on the
|
||||
player (the Plex player, the YouTube stage) and on every surface painted `--media-chrome` (its menus,
|
||||
panels and bars), and a form there (`media-scope`) re-points `--danger` too. A chosen item there is
|
||||
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
|
||||
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.
|
||||
@@ -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-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 | 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 |
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -36,9 +36,9 @@ 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` |
|
||||
| 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` |
|
||||
|
||||
@@ -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,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)]";
|
||||
+13
-7
@@ -33,6 +33,9 @@
|
||||
--color-accent: var(--accent);
|
||||
/* A focus ring's colour: the accent, unless a scope over another ground names its own. */
|
||||
--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-info: var(--info);
|
||||
--color-info-fg: var(--info-fg);
|
||||
@@ -145,18 +148,20 @@
|
||||
border-color: var(--edge-strong);
|
||||
}
|
||||
|
||||
/* The focus ring on the always-dark player chrome (a player, its menus and panels): the media amber,
|
||||
as a light theme's accent is dark (under 3:1 on the chrome). Every class string that paints
|
||||
bg-media-chrome names it or media-scope (theme.contract.test.ts). */
|
||||
@utility media-focus {
|
||||
/* The marks on the always-dark player chrome (a player, its menus and panels) — the focus ring and
|
||||
what is chosen — are the media amber, as a light theme's accent is dark there (Slate 2.84:1).
|
||||
Every class string that paints bg-media-chrome names it or media-scope (theme.contract.test.ts). */
|
||||
@utility media-marks {
|
||||
--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
|
||||
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
|
||||
there is not left to it: a focus ring is 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). */
|
||||
never lands on the dark bar. The accent stays the theme's for a choice FILLED with it (its own
|
||||
text on it holds); a mark standing on the chrome itself is not left to it: a focus ring and a
|
||||
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 {
|
||||
--fg: #ffffff;
|
||||
--muted: rgb(255 255 255 / 0.62);
|
||||
@@ -168,6 +173,7 @@
|
||||
--card: rgb(255 255 255 / 0.08);
|
||||
--danger: var(--media-danger);
|
||||
--focus: var(--media-mark);
|
||||
--chosen: var(--media-mark);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,18 +81,33 @@ describe("the media colours", () => {
|
||||
expect(field).not.toMatch(/\bfocus-within:(border|ring)-accent\b/);
|
||||
});
|
||||
|
||||
it("every surface painted the player chrome names its focus colour", () => {
|
||||
const at = css.indexOf("@utility media-focus {");
|
||||
expect(css.slice(at, css.indexOf("\n}", at))).toMatch(/--focus:\s*var\(--media-mark\);/);
|
||||
// A class string that paints the chrome carries the media focus (media-scope re-points it too):
|
||||
// the theme's accent ring fell under 3:1 on it in a light scheme.
|
||||
it("every surface painted the player chrome names its marks: the focus ring and what is chosen", () => {
|
||||
const block = (name: string) => {
|
||||
const at = css.indexOf(name);
|
||||
return css.slice(at, css.indexOf("\n}", at));
|
||||
};
|
||||
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 offenders: string[] = [];
|
||||
for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) {
|
||||
if (!/\.tsx?$/.test(f) || /\.test\.tsx?$/.test(f)) continue;
|
||||
const src = readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8");
|
||||
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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user