feat(design): C5 recorded, every E28 decision taken

The owner's lean Plex control set with both refinements (Back by position,
a touch double-tap for the 10 s skip) goes into DESIGN.md's Players
chapter and the log. The lab is now the reference only.
This commit is contained in:
2026-09-24 04:12:19 +02:00
parent 3f5634a44f
commit 9c4a999e54
4 changed files with 85 additions and 174 deletions
+46 -35
View File
@@ -4,9 +4,9 @@ This is the one written source of truth for the UI: principles, tokens, the cano
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
code is either a decision still open here (marked **Pending**) or a violation to fix.
**Status:** written in E28. Chapters marked _Decided_ are in force. One item is still **Pending** (C5, the
Plex control row on a narrow screen), rendered side by side in the design lab (below) and settled by the
product owner.
**Status:** written in E28; every chapter is _Decided_ and in force. The design lab (below) renders each rule's
chosen form. A new question opens a **Pending** item here, with its variants in the lab, until the product
owner settles it.
## How this document changes
@@ -214,20 +214,30 @@ Wherever there is text, the user can select it and copy it, and no click action
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
## Players — _Decided_ (C3), one item **Pending (C5)**
## Players — _Decided_ (C3, C5)
- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
click-to-pause. Measured live: today a tap with the bar hidden both reveals it and pauses. [Nielsen #3]
- **The hide timer starts with playback,** not with the first touch (today, on touch, the bar stays up until
the first tap however long the video plays).
click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3]
- **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until
the first tap however long the video played).
- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger
drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8]
- **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet.
- No Stop (closing the player is stopping), no Restart, no ±10 s buttons.
- **Back:** more than 3 s in → back to the start; within the first 3 s → the previous item, at its resume
point if it has one, else its start. The first item has only the restart.
- **Next** shows only when there is a next item (an episode, a playlist queue). It starts that item at its
resume point and leaves one behind on the current item, which does not count as watched unless it is
already inside the finish margin (the credits).
- **±10 s:** the keyboard keeps ← / →; on touch a double-tap on the left or right third of the picture skips
∓10 s, while a single tap still only shows the controls. Touch never loses a function. [WCAG 2.5.1]
- The row: play/pause, Back, Next, mute, the time, audio & subtitles, a fixed **⋯** (player settings, media
info, stats, keyboard shortcuts, download the original file), fullscreen.
- **Restarting lives on the media card** (C5): not started → Play; started → **Resume** (primary) + **From
the start**. On touch a tap resumes and the card's "…" menu has From the start.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13]
- **The control row on a narrow screen — Pending (C5):** today it runs off the right edge below 1024 (five
controls unreachable at 767 and at 820 portrait). Overflow into a "⋯ More" menu vs wrapping onto a second
line is open in the lab.
## Dialogs — _Decided_ (C4)
@@ -253,28 +263,29 @@ Wherever there is text, the user can select it and copy it, and no click action
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| Date | Decision | Chosen | Over | Why |
| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
+4 -19
View File
@@ -3,10 +3,7 @@ import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC, C5 } from "./batchC";
// The open decisions, in the order they are asked. The decided ones render below them as reference.
const OPEN = [{ id: "c5", code: "C5", title: "Player controls", render: C5 }] as const;
import { BatchC } from "./batchC";
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
@@ -93,15 +90,6 @@ export default function DesignLab() {
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
{OPEN.map((d) => (
<a
key={d.id}
href={`#${d.id}`}
className="rounded-md px-1.5 py-0.5 text-muted hover:bg-card hover:text-fg"
>
{d.code} {d.title}
</a>
))}
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
A ✓
</a>
@@ -116,15 +104,12 @@ export default function DesignLab() {
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
<header>
<h1 className="text-2xl font-bold">Open · the Plex player controls</h1>
<h1 className="text-2xl font-bold">The design reference</h1>
<p className="text-sm text-muted">
Each decision shows its variants with the app&apos;s real CSS. The framed one is the
recommendation. Resize the window to tablet size to see them there too.
Every rule of docs/DESIGN.md in its chosen rendering, with the app&apos;s real CSS.
Resize the window to tablet size to see them there too.
</p>
</header>
{OPEN.map((d) => (
<d.render key={d.id} />
))}
<header id="decided" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · batch A, controls</h2>
<p className="text-sm text-muted">
+30 -44
View File
@@ -22,13 +22,12 @@ import {
} from "lucide-react";
import { Btn } from "./batchA";
import { C1 } from "./batchCActions";
import { Decision, Reference, Sample, Variant } from "./labKit";
import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens";
// E28 S2 · Batch C — behaviour on touch and on a narrow window. C1–C4 are DECIDED (owner, 2026-09-24:
// all as proposed) and stay as references. C5 is open: the live player pass (E28 S2d) found the Plex
// control row running off-screen below 1024; the owner answered with a reworked control set, and
// two of its points are asked back.
// E28 S2 · Batch C — behaviour on touch and on a narrow window, DECIDED (owner, 2026-09-24): C1–C4 as
// proposed; C5 is the owner's reworked Plex control set (found by the live player pass, E28 S2d: the
// old row ran off-screen below 1024) with the two refinements they accepted. References only.
const ico = "w-4 h-4";
@@ -206,14 +205,14 @@ export function BatchC() {
<C3 />
<C4 />
</div>
<C5 />
</div>
);
}
// ── C5 · the Plex player's controls, reworked (the owner's model, 2026-09-24) ─────────────────────
// The owner's direction: Stop, Restart and ±10 s leave the row; Back restarts or steps to the previous
// episode; Next exists only when there is a next item; restarting moves onto the card. What is left
// fits a portrait tablet in one row, with a fixed "⋯" for the rare tools.
// ── C5 · the Plex player's controls (the owner's model, 2026-09-24) ──────────────────────────────
// What is left after Stop, Restart and ±10 s leave the row fits a portrait tablet in one row, with a
// fixed "⋯" for the rare tools; restarting moves onto the card.
const ROW: { icon: typeof Pause; label: string }[] = [
{ icon: Pause, label: "Play / pause" },
@@ -231,24 +230,22 @@ const MORE: { icon: typeof Pause; label: string }[] = [
const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function PlayerRow({ touchZones }: { touchZones: boolean }) {
function PlayerRow() {
return (
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
{touchZones && (
// Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch.
<div className="absolute inset-x-0 top-0 bottom-28 flex justify-between text-2xs text-white/70">
<span className="flex w-1/3 items-start justify-center border-r border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
<Rewind className="h-4 w-4" /> double-tap −10 s
</span>
{/* Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch. */}
<div className="absolute inset-x-0 top-0 bottom-28 flex justify-between text-2xs text-white/70">
<span className="flex w-1/3 items-start justify-center border-r border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
<Rewind className="h-4 w-4" /> double-tap −10 s
</span>
<span className="flex w-1/3 items-start justify-center border-l border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
double-tap +10 s <FastForward className="h-4 w-4" />
</span>
</span>
<span className="flex w-1/3 items-start justify-center border-l border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
double-tap +10 s <FastForward className="h-4 w-4" />
</span>
</div>
)}
</span>
</div>
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-linear-to-t from-black/85 to-transparent px-2 pb-2 pt-10">
<div className="relative flex h-11 items-center">
<div className="h-1 w-full rounded-full bg-white/30">
@@ -352,31 +349,20 @@ function Cards() {
);
}
export function C5() {
function C5() {
return (
<Decision
<Reference
id="c5"
code="C5"
title="The Plex player's controls, reworked"
question="Your model: no Stop (closing is stopping), no Restart and no ±10 s buttons, Back restarts or goes to the previous episode, Next only where there is a next item, and restarting moves onto the card (Play when not started; Resume + From the start when started). It fits a 767 portrait tablet in one row, with a fixed '⋯'. Two points need your call: how Back tells 'restart' from 'previous', and how a tablet skips 10 s without a keyboard."
now="15 controls plus the time in one row that neither wraps nor scrolls: at 767 and at 820 portrait five of them are off-screen and unreachable. The card shows one Play ring; Resume and 'Clear resume point' exist only on the detail page."
title="The Plex player's controls"
rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Resume + From the start (touch: a tap resumes, the '…' has From the start)."
>
<Variant
letter="A"
name="Your model, with two refinements"
recommended
why="1) Back decides by the position, the music-player rule: more than 3 s in → restart; within the first 3 s → the previous episode (at its resume point if it has one, else its start). That is your 'first press restarts, the second goes back', without a press counter: a counter goes wrong when you arrive at an episode by Next and want the one before it at once (the first press would restart an episode already at 0:00). 2) On touch, a double-tap on the left or right third of the picture skips ∓10 s (the YouTube and Netflix gesture); a single tap still only shows the controls (C3). Without it a tablet can't go back 10 s at all: on a 42-minute film one pixel of the seek bar is ≈ 3.5 s, and a finger lands within 10–20 px, so ±40–70 s."
>
<PlayerRow touchZones />
<div className="flex flex-wrap items-start gap-4">
<div className="w-full max-w-xl">
<PlayerRow />
</div>
<Cards />
</Variant>
<Variant
letter="B"
name="Your model, literally"
why="Back counts presses: the first restarts, a second one goes to the previous episode. ±10 s only from the keyboard (← / →), so on a tablet the seek bar is the only way to move, at a precision of tens of seconds. The row and the cards are the same as A."
>
<PlayerRow touchZones={false} />
</Variant>
</Decision>
</div>
</Reference>
);
}
+5 -76
View File
@@ -1,84 +1,13 @@
import { useSyncExternalStore, type ReactNode } from "react";
import clsx from "clsx";
// Building blocks of the design lab. A lab page asks the owner ONE question per <Decision>: two or
// three <Variant>s rendered side by side with the app's real CSS, the recommended one marked and its
// one-line reason (the rule it rests on) printed under it. Everything is static markup on purpose —
// what is being decided is the look, and a variant that only exists here must not need app state.
export function Decision({
id,
code,
title,
question,
now,
children,
}: {
id: string;
code: string;
title: string;
question: string;
/** What the app does today, measured (the E28 S1 inventory). */
now: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className="glass rounded-2xl p-5 scroll-mt-20">
<header className="mb-4">
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
<h2 className="text-lg font-bold">{title}</h2>
</div>
<p className="mt-1 text-sm">{question}</p>
<p className="mt-1 text-xs text-muted">
<span className="font-semibold">Today: </span>
{now}
</p>
</header>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">{children}</div>
</section>
);
}
export function Variant({
letter,
name,
recommended = false,
why,
children,
}: {
letter: string;
name: string;
recommended?: boolean;
why: string;
children: ReactNode;
}) {
return (
<article
className={clsx(
"glass-card rounded-xl p-4 flex flex-col gap-3 border",
recommended ? "border-accent" : "border-transparent",
)}
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-card text-xs font-bold">
{letter}
</span>
<h3 className="text-sm font-semibold flex-1">{name}</h3>
{recommended && (
<span className="rounded-full bg-accent px-2 py-0.5 text-2xs font-bold text-accent-fg">
Recommended
</span>
)}
</div>
<div className="flex flex-col gap-3">{children}</div>
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
</article>
);
}
// Building blocks of the design lab. Every E28 decision is taken, so the lab is the reference half
// only: each <Reference> is the chosen rendering with the rule it became. A new design round brings
// back the side-by-side <Decision>/<Variant> pair (E28 S2 history) for its open questions. Everything
// is static markup on purpose: a rendering that only exists here must not need app state.
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
* reference half — the living style guide the open decisions sit on top of. */
* reference half — the living style guide. */
export function Reference({
id,
code,