diff --git a/CLAUDE.md b/CLAUDE.md index c256bd4f..846ffe0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b35442d8..218d2ef7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 | diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index d4e756a5..082fe312 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -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 , so it owns the theme attributes on itself, through the same data-theme / @@ -114,12 +108,15 @@ export default function DesignLab() { B ✓ + + C ✓ +
-

Batch C · behaviour and device

+

Open · the player row on a narrow screen

Each decision shows its variants with the app'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() {

+
+

Decided · batch C, behaviour and device

+

+ C1 is the action classification S3 builds each object's one action list from. +

+
+
); diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx index 094ec1cd..5189e732 100644 --- a/frontend/src/design-lab/batchC.tsx +++ b/frontend/src/design-lab/batchC.tsx @@ -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 ( -
- {Array.from({ length: cols * 3 }, (_, i) => ( +
+ {Array.from({ length: 9 }, (_, i) => (
Video {i + 1} @@ -37,19 +55,15 @@ function Content({ cols = 3 }: { cols?: number }) { ); } -function FilterPanel({ pinnable }: { pinnable?: boolean }) { +function FilterPanel() { return (
Filters - {pinnable && ( - - - - )} + + + @@ -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 ( - - - - -
-
- -
- - - - -
- -
- -
-
-
-
- + + +
+
+ +
+ + ); } -// ── 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 ( -
-
- {/* The seek bar: the visible track stays thin; on touch the hit area around it is 44 tall. */} -
-
-
-
- {touch && ( - - )} -
-
- {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => ( - - - - ))} - 12:04 / 31:40 - - - -
-
-
- ); -} - -export function C3() { - return ( - - - - - - - - +
+
+ {/* The visible track stays thin; the dashed box is the 44-tall hit area around it. */} +
+
+
+
+ +
+
+ {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => ( + + + + ))} + 12:04 / 31:40 + + + +
+
+
+ ); } @@ -187,46 +156,34 @@ function Field({ label, value, focused }: { label: string; value: string; focuse ); } -function ShortDialog({ pinned }: { pinned: boolean }) { - const fields = ( -
- - - - - {!pinned && ( -
- - Cancel - - - Save - -
- )} -
- ); +function C4() { return ( -
-
- Edit details - +
+
- - -
-
{fields}
- {pinned && ( + Edit details + + + +
+
+
+ + + + +
+
Cancel @@ -235,34 +192,132 @@ function ShortDialog({ pinned }: { pinned: boolean }) { Save
- )} +
+ + ); +} + +export function BatchC() { + return ( +
+ +
+ + + +
); } -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 ( +
+
+
+
+
+
+ +
+ {children} +
+
+ ); +} + +function OverflowRow() { + return ( + +
+ {CONTROLS.filter((c) => c.keep).map((c) => ( + + + + ))} + 12:04 / 42:21 + + + + + + +
+
+ {CONTROLS.filter((c) => !c.keep).map((c) => ( + + + {c.label} + + ))} +
+ + ); +} + +function WrappedRow() { + return ( + +
+ {CONTROLS.map((c) => ( + + + + ))} + + + + 12:04 / 42:21 +
+ + ); +} + +export function C5() { return ( - + - + ); diff --git a/frontend/src/design-lab/batchCActions.tsx b/frontend/src/design-lab/batchCActions.tsx index 7bc9ebbe..1fd014ed 100644 --- a/frontend/src/design-lab/batchCActions.tsx +++ b/frontend/src/design-lab/batchCActions.tsx @@ -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 ( - - -
- - - - - - -
-
- - - - {BACKGROUND.map(([page, items]) => ( - - - - - ))} - -
{page}{items}
-
-
- +
+ + + + + + + +
+ + {BACKGROUND.map(([page, items]) => ( + + + + + ))} + +
{page}{items}
+
-
+ + ); }