feat(design): batch C recorded, the player row open as C5 (E28 S2d)

C1-C4 as proposed: the action classification, overlay-drawer side panels,
touch-first players, dialog focus by purpose with a pinned footer. The
players were then played live at tablet size, which found the Plex
control row running off-screen below 1024; how it fits is open as C5.
CLAUDE.md now points at DESIGN.md.
This commit is contained in:
2026-09-24 03:27:26 +02:00
parent 7055548260
commit f4bc60d659
5 changed files with 318 additions and 234 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ how it gets built. Concretely:
behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
choose from before building.
choose from before building. How the UI looks and behaves is decided in
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
and change it only through its decision log.
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
arguments, push back or propose an alternative — don't comply blindly.
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
+65 -29
View File
@@ -4,8 +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:** being written in E28. Chapters marked _Decided_ are in force. Chapters marked **Pending** are
open decisions, each rendered side by side in the design lab (below) and settled by the product owner.
**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.
## How this document changes
@@ -58,7 +59,12 @@ style guide.
(A3, under Controls). [HIG, WCAG 2.5.8]
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
when an input gets focus.
- **Below 768px** (a narrow window, split screen): what replaces the side rails, and how a side panel opens on touch, is **Pending (C2)**. The Plex player on touch is **Pending (C3)**.
- **Side panels (C2):** on touch, and at any width below 768 (a narrow window, split screen), the rail tab
or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc
or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered
only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and
click-to-pin. A filter is never unreachable at any supported width. [Nielsen #7, WCAG 1.4.10]
- **Players on touch (C3):** see [Players](#players).
## Tokens
@@ -159,8 +165,17 @@ Which actions are visible and which live in a menu.
- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
Destructive ones keep their confirmation. [Nielsen #8, #5]
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. _Which actions stay
visible per object is the classification table, **Pending review (C1)**._
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu.
- **The classification (C1)** says, per object, what is visible at rest, on hover and on touch; its full
table is the lab's C1 reference, and S3 turns it into each object's one action list in code. In short:
- A video or Plex card: play and its state (watched, progress) at rest; the everyday toggles (watched,
save, add to playlist) and "…" on hover; on touch the state and a 44px "…" beside the title. Details,
download, hide and reset live in the menu.
- A row (playlist item, channel, download): its primary action and its values (priority, tags, error)
stay visible; destructive and rare row buttons (unsubscribe, delete, cancel, remove) move into the menu.
A playlist item's X column goes: Delete key plus an undo toast.
- Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive
header action ("Clear everything", "Purge discovery") goes into the header's "…".
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
@@ -199,10 +214,27 @@ 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]
## Dialogs — **Pending (C4)**
## Players — _Decided_ (C3), one item **Pending (C5)**
Where focus lands when a dialog opens (by purpose, APG), and whether the footer stays pinned while the body
scrolls. Open in the lab.
- **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).
- **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 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)
- **Initial focus by purpose** (a `Modal` `initialFocus` rule): a form focuses its first field, a confirm its
least destructive button, anything else Close. [WAI-ARIA APG]
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at both edges, so the
primary action stays on screen when the on-screen keyboard takes half of it. [Nielsen #3, WCAG 2.4.11]
## Copy
@@ -221,24 +253,28 @@ scrolls. Open in the lab.
## 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 |
| 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 |
+13 -9
View File
@@ -3,16 +3,10 @@ import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { C1 } from "./batchCActions";
import { C2, C3, C4 } from "./batchC";
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: "c1", code: "C1", title: "Action placement", render: C1 },
{ id: "c2", code: "C2", title: "Side panels", render: C2 },
{ id: "c3", code: "C3", title: "Player touch", render: C3 },
{ id: "c4", code: "C4", title: "Dialogs", render: C4 },
] as const;
const OPEN = [{ id: "c5", code: "C5", title: "Player row", render: C5 }] as const;
// 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 /
@@ -114,12 +108,15 @@ export default function DesignLab() {
<a href="#decided-b" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
B ✓
</a>
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
C ✓
</a>
</nav>
</div>
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
<header>
<h1 className="text-2xl font-bold">Batch C · behaviour and device</h1>
<h1 className="text-2xl font-bold">Open · the player row on a narrow screen</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.
@@ -142,6 +139,13 @@ export default function DesignLab() {
</p>
</header>
<BatchB />
<header id="decided-c" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · batch C, behaviour and device</h2>
<p className="text-sm text-muted">
C1 is the action classification S3 builds each object&apos;s one action list from.
</p>
</header>
<BatchC />
</main>
</div>
);
+211 -156
View File
@@ -1,13 +1,34 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import { Maximize, Pause, Pin, SkipForward, Subtitles, Volume2, X } from "lucide-react";
import {
Activity,
Download,
FastForward,
Info,
Keyboard,
Maximize,
MoreHorizontal,
Pause,
Pin,
Rewind,
RotateCcw,
Settings,
SkipBack,
SkipForward,
Square,
Subtitles,
Volume2,
X,
} from "lucide-react";
import { Btn } from "./batchA";
import { Decision, Sample, Variant } from "./labKit";
import { C1 } from "./batchCActions";
import { Decision, Reference, Sample, Variant } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens";
// E28 S2 · Batch C, part 2 — behaviour on touch and on a narrow window: the side panels, the player,
// the dialog conventions. These are behaviours, so each variant is a still of the moment that
// differs, with the sequence written under it.
// 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, which C3's 44px buttons make worse, so how the row
// fits is asked on its own.
const ico = "w-4 h-4";
@@ -21,13 +42,10 @@ function Screen({ label, children }: { label: string; children: ReactNode }) {
);
}
function Content({ cols = 3 }: { cols?: number }) {
function Content() {
return (
<div
className="grid gap-2 p-3"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
>
{Array.from({ length: cols * 3 }, (_, i) => (
<div className="grid grid-cols-3 gap-2 p-3">
{Array.from({ length: 9 }, (_, i) => (
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
<span className="truncate text-2xs">Video {i + 1}</span>
@@ -37,19 +55,15 @@ function Content({ cols = 3 }: { cols?: number }) {
);
}
function FilterPanel({ pinnable }: { pinnable?: boolean }) {
function FilterPanel() {
return (
<div className="glass flex h-full w-44 flex-col gap-2 rounded-r-2xl p-3 text-xs">
<div className="flex items-center justify-between">
<span className="font-semibold">Filters</span>
<span className="flex gap-0.5">
{pinnable && (
<span
className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}
>
<Pin className="h-3.5 w-3.5" />
</span>
)}
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}>
<Pin className="h-3.5 w-3.5" />
</span>
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}>
<X className="h-3.5 w-3.5" />
</span>
@@ -65,106 +79,61 @@ function FilterPanel({ pinnable }: { pinnable?: boolean }) {
}
// ── C2 · side panels on touch and on a narrow window ─────────────────────────────────────────────
export function C2() {
function C2() {
return (
<Decision
<Reference
id="c2"
code="C2"
title="Side panels on touch and on a narrow window"
question="How does a side panel (the feed filters, the Plex sidebar, the playlists rail, the channel picker) open on a tablet, and what replaces it below 768px?"
now="Below 768 the panels are hidden (SidePanel is hidden md:block) and their filters are unreachable. On touch at 768+, a tap on the rail tab fires the synthetic hover and then the click, which PINS the panel: at 1024 wide a pinned panel pushes the feed from 3 to 2 columns. There is no 'open once' on touch."
rule="On touch, and at any width below 768, the rail tab (or the band's 'Filters · n' button) slides the panel OVER the content with a scrim; a tap outside, Esc or Back closes it and the content never reflows. Pin is its own button in the panel header, offered only ≥ 1024 wide. The mouse keeps hover-to-peek and click-to-pin."
>
<Variant
letter="A"
name="A tap opens an overlay drawer; pinning is a separate button"
recommended
why="On touch, and at any width below 768, the tab (or the band's 'Filters · n' button, B4) slides the panel over the content with a scrim. A tap outside, Esc or Back closes it, and the content never reflows. Pin is its own button in the panel's header, offered only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps today's hover-to-peek and click-to-pin."
>
<Screen label="Tablet: after one tap on the tab">
<Content />
<div className="absolute inset-0 bg-black/40" />
<div className="absolute inset-y-0 left-0">
<FilterPanel pinnable />
</div>
</Screen>
</Variant>
<Variant
letter="B"
name="Today: a tap pins, the content reflows"
why="A tap is taken as the mouse's click, so the panel pins and pushes the grid to two columns. Closing it needs a second tap on a small tab. Below 768 the filters are simply gone."
>
<Screen label="Tablet: after one tap on the tab">
<div className="flex h-full">
<FilterPanel />
<div className="min-w-0 flex-1">
<Content cols={2} />
</div>
</div>
</Screen>
</Variant>
</Decision>
<Screen label="Tablet: after one tap on the tab">
<Content />
<div className="absolute inset-0 bg-black/40" />
<div className="absolute inset-y-0 left-0">
<FilterPanel />
</div>
</Screen>
</Reference>
);
}
// ── C3 · the player on touch ─────────────────────────────────────────────────────────────────────
function Player({ touch }: { touch: boolean }) {
const btn = touch ? "h-11 w-11" : "h-8 w-8";
// ── C3 · the players on touch ────────────────────────────────────────────────────────────────────
function C3() {
const btn = "grid h-11 w-11 place-items-center rounded-full";
return (
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-linear-to-t from-black/80 to-transparent px-3 pb-2 pt-8">
{/* The seek bar: the visible track stays thin; on touch the hit area around it is 44 tall. */}
<div
className={clsx("relative flex items-center", touch ? "h-11" : "h-1.5")}
style={touch ? { outline: "1px dashed rgb(255 255 255 / 0.35)" } : undefined}
>
<div className="h-1 w-full rounded-full bg-white/30">
<div className="h-1 w-2/5 rounded-full bg-accent" />
</div>
{touch && (
<span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" />
)}
</div>
<div className="flex items-center gap-1">
{[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
<span key={i} className={clsx("grid place-items-center rounded-full", btn)}>
<I className="h-5 w-5" />
</span>
))}
<span className="ml-auto text-2xs text-white/80">12:04 / 31:40</span>
<span className={clsx("grid place-items-center rounded-full", btn)}>
<Maximize className="h-5 w-5" />
</span>
</div>
</div>
</div>
);
}
export function C3() {
return (
<Decision
<Reference
id="c3"
code="C3"
title="The Plex player on touch"
question="What does a tap on the video do, and how big are the seek bar and the controls under a finger? (Measured from the code, not played, so no watch state was written.)"
now="A tap reaches both the wrapper (wake the controls) and the <video> (toggle play), so the tap that reveals the hidden controls also pauses. The seek bar's hit area is 6px tall and its head appears only on hover. The buttons are ≈ 32px, their labels only on hover."
title="The players on touch"
rule="Plex: a tap on the video only shows or hides the controls; play/pause is the button (the mouse keeps click-to-pause). The hide timer starts with playback, not with the first touch. The seek track stays thin inside a 44px hit area with touch-action: none, its head always visible. Buttons 44. The YouTube modal's chrome uses the Button rungs (44 on touch), its side arrows stay inside the screen, and its description opens from an ⓘ button, because the hover popover never opens on a tap."
>
<Variant
letter="A"
name="Tap = show the controls; play is a button"
recommended
why="On touch the first tap only shows the controls (a second tap on the empty video hides them). Play/pause is the button, never the tap on the video. The seek track stays thin but sits in a 44px-tall hit area (dashed), with the head always visible. The buttons are 44 (A3). The YouTube and Apple TV apps work this way. The mouse keeps click-to-pause."
>
<Player touch />
</Variant>
<Variant
letter="B"
name="Today"
why="Every tap that wakes the controls also pauses, so you can't reach the seek bar without stopping the video, and the 6px seek bar is hard to catch."
>
<Player touch={false} />
</Variant>
</Decision>
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-linear-to-t from-black/80 to-transparent px-3 pb-2 pt-8">
{/* The visible track stays thin; the dashed box is the 44-tall hit area around it. */}
<div
className="relative flex h-11 items-center"
style={{ outline: "1px dashed rgb(255 255 255 / 0.35)" }}
>
<div className="h-1 w-full rounded-full bg-white/30">
<div className="h-1 w-2/5 rounded-full bg-accent" />
</div>
<span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" />
</div>
<div className="flex items-center gap-1">
{[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
<span key={i} className={btn}>
<I className="h-5 w-5" />
</span>
))}
<span className="ml-auto text-2xs text-white/80">12:04 / 31:40</span>
<span className={btn}>
<Maximize className="h-5 w-5" />
</span>
</div>
</div>
</div>
</Reference>
);
}
@@ -187,46 +156,34 @@ function Field({ label, value, focused }: { label: string; value: string; focuse
);
}
function ShortDialog({ pinned }: { pinned: boolean }) {
const fields = (
<div className="flex flex-col gap-2 px-3 pb-3 pt-2">
<Field label="Title" value="Building a home lab" focused={pinned} />
<Field label="Channel" value="Techno Tim" />
<Field label="Tags" value="homelab, proxmox" />
<Field label="Note" value="Watch with the team" />
{!pinned && (
<div className="flex justify-end gap-2 pt-1">
<Btn tone="ghost" size="md">
Cancel
</Btn>
<Btn tone="primary" size="md">
Save
</Btn>
</div>
)}
</div>
);
function C4() {
return (
<div className="glass relative flex h-52 flex-col overflow-hidden rounded-2xl">
<div
className={clsx(
"relative z-base flex items-center justify-between px-3 py-2 text-sm font-semibold",
FADE_BELOW_PANEL,
)}
>
Edit details
<span
<Reference
id="c4"
code="C4"
title="Dialog conventions"
rule="Modal takes an initialFocus by purpose (APG): a form focuses its first field, a confirm its least destructive button, anything else Close. The header and the footer are fixed, only the body scrolls, with the B9 fade at both edges, so Save stays one tap away with the keyboard up. Shown at a keyboard-up height."
>
<div className="glass relative flex h-52 flex-col overflow-hidden rounded-2xl">
<div
className={clsx(
"grid place-items-center rounded-lg text-muted",
H.iconSm,
!pinned && "ring-2 ring-accent",
"relative z-base flex items-center justify-between px-3 py-2 text-sm font-semibold",
FADE_BELOW_PANEL,
)}
>
<X className={ico} />
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">{fields}</div>
{pinned && (
Edit details
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm)}>
<X className={ico} />
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
<div className="flex flex-col gap-2 px-3 pb-3 pt-2">
<Field label="Title" value="Building a home lab" focused />
<Field label="Channel" value="Techno Tim" />
<Field label="Tags" value="homelab, proxmox" />
<Field label="Note" value="Watch with the team" />
</div>
</div>
<div className={clsx("relative z-base flex justify-end gap-2 px-3 py-2", FADE_ABOVE_PANEL)}>
<Btn tone="ghost" size="md">
Cancel
@@ -235,34 +192,132 @@ function ShortDialog({ pinned }: { pinned: boolean }) {
Save
</Btn>
</div>
)}
</div>
</Reference>
);
}
export function BatchC() {
return (
<div className="flex flex-col gap-4">
<C1 />
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]">
<C2 />
<C3 />
<C4 />
</div>
</div>
);
}
export function C4() {
// ── C5 · the Plex control row on a narrow screen ─────────────────────────────────────────────────
// Every control of today's row, in the order it sits; `keep` = stays in the row under variant A.
const CONTROLS: { icon: typeof Pause; label: string; keep: boolean }[] = [
{ icon: Rewind, label: "Back 10 s", keep: true },
{ icon: Pause, label: "Play / pause", keep: true },
{ icon: FastForward, label: "Forward 10 s", keep: true },
{ icon: RotateCcw, label: "Restart from the beginning", keep: false },
{ icon: Square, label: "Stop", keep: false },
{ icon: SkipBack, label: "Previous episode", keep: true },
{ icon: SkipForward, label: "Next episode", keep: true },
{ icon: Volume2, label: "Mute", keep: true },
{ icon: Subtitles, label: "Audio & subtitles", keep: true },
{ icon: Settings, label: "Settings", keep: false },
{ icon: Activity, label: "Stats for nerds", keep: false },
{ icon: Info, label: "Media info", keep: false },
{ icon: Keyboard, label: "Keyboard shortcuts", keep: false },
{ icon: Download, label: "Download original file", keep: false },
];
const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function Frame({ children }: { children: ReactNode }) {
// Stands in for a portrait tablet: the frame is the screen width, narrower than the full row.
return (
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
<div className="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">
<div className="h-1 w-2/5 rounded-full bg-accent" />
</div>
<span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" />
</div>
{children}
</div>
</div>
);
}
function OverflowRow() {
return (
<Frame>
<div className="flex items-center">
{CONTROLS.filter((c) => c.keep).map((c) => (
<span key={c.label} className={ctl} title={c.label}>
<c.icon className="h-5 w-5" />
</span>
))}
<span className="ml-auto truncate px-1 text-2xs text-white/80">12:04 / 42:21</span>
<span className={clsx(ctl, "bg-white/15")} title="More">
<MoreHorizontal className="h-5 w-5" />
</span>
<span className={ctl} title="Fullscreen">
<Maximize className="h-5 w-5" />
</span>
</div>
<div className="glass-menu absolute bottom-16 right-2 flex w-56 flex-col rounded-xl p-1 text-xs text-fg">
{CONTROLS.filter((c) => !c.keep).map((c) => (
<span key={c.label} className="flex min-h-11 items-center gap-2.5 rounded-lg px-2.5">
<c.icon className={ico} />
{c.label}
</span>
))}
</div>
</Frame>
);
}
function WrappedRow() {
return (
<Frame>
<div className="flex flex-wrap items-center">
{CONTROLS.map((c) => (
<span key={c.label} className={ctl} title={c.label}>
<c.icon className="h-5 w-5" />
</span>
))}
<span className={ctl} title="Fullscreen">
<Maximize className="h-5 w-5" />
</span>
<span className="ml-auto px-1 text-2xs text-white/80">12:04 / 42:21</span>
</div>
</Frame>
);
}
export function C5() {
return (
<Decision
id="c4"
code="C4"
title="Dialog conventions"
question="Where does focus land when a dialog opens, and does the footer stay on screen when the keyboard takes half of it? Shown at a keyboard-up height."
now="Initial focus is split: most dialogs focus Close, Edit details its first input, Confirm focuses Cancel. With the keyboard up (1024×420) the Save footer scrolls with the body and sits below the fold."
id="c5"
code="C5"
title="The Plex control row on a narrow screen"
question="Found while playing the player at tablet size (C3 was measured live after you asked): the row has 15 controls plus the time. How should it fit when the screen is narrower than the row? Each frame is narrower than the row, as a portrait tablet is; the buttons are C3's 44."
now="The row neither wraps nor scrolls. At 767 and at 820 portrait, Stats, Media info, Keyboard shortcuts, Download and Fullscreen sit past the right edge and cannot be reached; at 1024 it just fits (1022 of 1024). With 44px buttons it would need ≈ 780px."
>
<Variant
letter="A"
name="Focus by purpose; the footer is pinned"
name="Everyday controls stay, the rest go into ⋯ More"
recommended
why="Modal gets an initialFocus rule (APG): a form focuses its first field, a confirm its least destructive button, anything else Close. The header and footer are fixed and only the body scrolls, with B9's fade at both edges, so Save is always one tap away, keyboard up or not."
why="The row keeps what you use while watching: skip ±10, play, previous / next episode, mute, audio & subtitles, fullscreen. Restart, Stop, Settings, Stats, Media info, Shortcuts and Download move into a ⋯ menu, but only when the row runs out of room: on a wide screen it looks like today. One row covers the least of the picture. This is how the YouTube, Netflix and Plex apps do it on a tablet."
>
<ShortDialog pinned />
<OverflowRow />
</Variant>
<Variant
letter="B"
name="Focus on Close; the footer scrolls with the body"
why="Today's most common behaviour. Opening a form means a second tap into the first field, and with the keyboard up Save is below the fold until you scroll down to it."
name="The row wraps onto a second line"
why="Every control stays one tap away, nothing is hidden. The price: the chrome is twice as tall and covers more of the picture while it is up, and where each button sits changes with the screen width, so muscle memory doesn't carry between the tablet and the desktop."
>
<ShortDialog pinned={false} />
<WrappedRow />
</Variant>
</Decision>
);
+26 -39
View File
@@ -1,10 +1,11 @@
import clsx from "clsx";
import { Bookmark, Check, ListPlus, MoreHorizontal, Play } from "lucide-react";
import { Decision, Sample, Variant } from "./labKit";
import { Reference, Sample } from "./labKit";
import { H, LAYER } from "./labTokens";
// E28 S2 · Batch C, part 1 — action placement: the classification table (object × action → where it
// lives). The owner delegated this to the UX lead, so it is reviewed rather than picked. It applies
// lives), DECIDED (owner, 2026-09-24: accepted as proposed). It is the source S3 builds each object's
// ONE action list from. It applies
// the decided guard rails (DESIGN.md "Action placement"): ONE action list per object feeds every
// trigger; the primary action and visible state stay on the object; rare, secondary and destructive
// actions go to the menu; nothing is gesture-only, because the "…" button opens the same menu.
@@ -370,47 +371,33 @@ function Card({ touch }: { touch: boolean }) {
export function C1() {
return (
<Decision
<Reference
id="c1"
code="C1"
title="Action placement: the classification"
question="Where each object's actions live. You delegated this one, so it is for review, not a pick: say where you disagree. The menu (right-click, long-press, Shift+F10 or the '…' button) always lists ALL of an object's actions from one list; the table says what is ALSO visible."
now="No object gets its buttons and its menu from one list: the feed card's menu re-declares watched / save / hide / reset and leaves out add-to-playlist and download (hover only). Move up / down and pin live only in menus. Downloads, Notifications and the Scheduler have no menu at all; Channels and Plex menus offer only Pin."
rule="The menu (right-click, long-press, Shift+F10 or the '…' button) lists ALL of an object's actions from one list; the table says what is ALSO visible. Mouse: the primary action and the state at rest, the everyday toggles on hover. Touch: the state and a 44px '…'. Destructive row buttons move into the menu."
>
<Variant
letter="A"
name="The card, mouse (hovered) and touch"
recommended
why="Mouse: play, the watched / progress state and the channel at rest; the three everyday toggles plus '…' on hover. Touch: nothing to hover, so the card keeps its state and a 44px '…' next to the title; everything else is in the menu. The title is plain, selectable text."
>
<div className="flex flex-wrap gap-4">
<Sample label="Mouse, hovered">
<Card touch={false} />
</Sample>
<Sample label="Touch">
<Card touch />
</Sample>
</div>
</Variant>
<Variant
letter="B"
name="The background menu (desktop only)"
why="A right-click on empty content area opens the page's menu. It only mirrors toolbar actions that already exist; Shift+right-click still gives the browser's own menu. Not on touch: a long-press on the background would fight scrolling and text selection."
>
<table className="text-xs">
<tbody>
{BACKGROUND.map(([page, items]) => (
<tr key={page} className="align-top [&>td]:py-1 [&>td]:pr-3">
<td className="whitespace-nowrap font-medium">{page}</td>
<td className="text-muted">{items}</td>
</tr>
))}
</tbody>
</table>
</Variant>
<div className="col-span-full">
<Table />
<div className="flex flex-wrap gap-4">
<Sample label="Card · mouse, hovered">
<Card touch={false} />
</Sample>
<Sample label="Card · touch">
<Card touch />
</Sample>
<Sample label="The background menu (desktop only; it mirrors the toolbar, Shift+right-click = the browser's)">
<table className="text-xs">
<tbody>
{BACKGROUND.map(([page, items]) => (
<tr key={page} className="align-top [&>td]:py-1 [&>td]:pr-3">
<td className="whitespace-nowrap font-medium">{page}</td>
<td className="text-muted">{items}</td>
</tr>
))}
</tbody>
</table>
</Sample>
</div>
</Decision>
<Table />
</Reference>
);
}