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:
@@ -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
|
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.
|
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
|
- **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
|
- **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.
|
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
|
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||||
|
|||||||
+65
-29
@@ -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
|
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.
|
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
|
**Status:** written in E28. Chapters marked _Decided_ are in force. One item is still **Pending** (C5, the
|
||||||
open decisions, each rendered side by side in the design lab (below) and settled by the product owner.
|
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
|
## How this document changes
|
||||||
|
|
||||||
@@ -58,7 +59,12 @@ style guide.
|
|||||||
(A3, under Controls). [HIG, WCAG 2.5.8]
|
(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
|
- **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.
|
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
|
## 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
|
- **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.
|
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
|
||||||
Destructive ones keep their confirmation. [Nielsen #8, #5]
|
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
|
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu.
|
||||||
visible per object is the classification table, **Pending review (C1)**._
|
- **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
|
- **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
|
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.
|
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.
|
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
|
||||||
[WCAG 2.3.3]
|
[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
|
- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
|
||||||
scrolls. Open in the lab.
|
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
|
## Copy
|
||||||
|
|
||||||
@@ -221,24 +253,28 @@ scrolls. Open in the lab.
|
|||||||
|
|
||||||
## Decision log
|
## Decision log
|
||||||
|
|
||||||
| Date | Decision | Chosen | Over | Why |
|
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 |
|
||||||
|
|||||||
@@ -3,16 +3,10 @@ import clsx from "clsx";
|
|||||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||||
import { BatchA } from "./batchA";
|
import { BatchA } from "./batchA";
|
||||||
import { BatchB } from "./batchB";
|
import { BatchB } from "./batchB";
|
||||||
import { C1 } from "./batchCActions";
|
import { BatchC, C5 } from "./batchC";
|
||||||
import { C2, C3, C4 } from "./batchC";
|
|
||||||
|
|
||||||
// The open decisions, in the order they are asked. The decided ones render below them as reference.
|
// The open decisions, in the order they are asked. The decided ones render below them as reference.
|
||||||
const OPEN = [
|
const OPEN = [{ id: "c5", code: "C5", title: "Player row", render: C5 }] as const;
|
||||||
{ 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;
|
|
||||||
|
|
||||||
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
|
// 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 /
|
// 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">
|
<a href="#decided-b" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
|
||||||
B ✓
|
B ✓
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
|
||||||
|
C ✓
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
|
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
|
||||||
<header>
|
<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">
|
<p className="text-sm text-muted">
|
||||||
Each decision shows its variants with the app's real CSS. The framed one is the
|
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.
|
recommendation. Resize the window to tablet size to see them there too.
|
||||||
@@ -142,6 +139,13 @@ export default function DesignLab() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<BatchB />
|
<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's one action list from.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<BatchC />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+211
-156
@@ -1,13 +1,34 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { ReactNode } from "react";
|
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 { 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";
|
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,
|
// E28 S2 · Batch C — behaviour on touch and on a narrow window. C1–C4 are DECIDED (owner, 2026-09-24:
|
||||||
// the dialog conventions. These are behaviours, so each variant is a still of the moment that
|
// all as proposed) and stay as references. C5 is open: the live player pass (E28 S2d) found the Plex
|
||||||
// differs, with the sequence written under it.
|
// 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";
|
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 (
|
return (
|
||||||
<div
|
<div className="grid grid-cols-3 gap-2 p-3">
|
||||||
className="grid gap-2 p-3"
|
{Array.from({ length: 9 }, (_, i) => (
|
||||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
|
||||||
>
|
|
||||||
{Array.from({ length: cols * 3 }, (_, i) => (
|
|
||||||
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
|
<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" />
|
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
|
||||||
<span className="truncate text-2xs">Video {i + 1}</span>
|
<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 (
|
return (
|
||||||
<div className="glass flex h-full w-44 flex-col gap-2 rounded-r-2xl p-3 text-xs">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-semibold">Filters</span>
|
<span className="font-semibold">Filters</span>
|
||||||
<span className="flex gap-0.5">
|
<span className="flex gap-0.5">
|
||||||
{pinnable && (
|
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}>
|
||||||
<span
|
<Pin className="h-3.5 w-3.5" />
|
||||||
className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}
|
</span>
|
||||||
>
|
|
||||||
<Pin className="h-3.5 w-3.5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}>
|
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
@@ -65,106 +79,61 @@ function FilterPanel({ pinnable }: { pinnable?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── C2 · side panels on touch and on a narrow window ─────────────────────────────────────────────
|
// ── C2 · side panels on touch and on a narrow window ─────────────────────────────────────────────
|
||||||
export function C2() {
|
function C2() {
|
||||||
return (
|
return (
|
||||||
<Decision
|
<Reference
|
||||||
id="c2"
|
id="c2"
|
||||||
code="C2"
|
code="C2"
|
||||||
title="Side panels on touch and on a narrow window"
|
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?"
|
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."
|
||||||
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."
|
|
||||||
>
|
>
|
||||||
<Variant
|
<Screen label="Tablet: after one tap on the tab">
|
||||||
letter="A"
|
<Content />
|
||||||
name="A tap opens an overlay drawer; pinning is a separate button"
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
recommended
|
<div className="absolute inset-y-0 left-0">
|
||||||
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."
|
<FilterPanel />
|
||||||
>
|
</div>
|
||||||
<Screen label="Tablet: after one tap on the tab">
|
</Screen>
|
||||||
<Content />
|
</Reference>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── C3 · the player on touch ─────────────────────────────────────────────────────────────────────
|
// ── C3 · the players on touch ────────────────────────────────────────────────────────────────────
|
||||||
function Player({ touch }: { touch: boolean }) {
|
function C3() {
|
||||||
const btn = touch ? "h-11 w-11" : "h-8 w-8";
|
const btn = "grid h-11 w-11 place-items-center rounded-full";
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
|
<Reference
|
||||||
<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
|
|
||||||
id="c3"
|
id="c3"
|
||||||
code="C3"
|
code="C3"
|
||||||
title="The Plex player on touch"
|
title="The players 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.)"
|
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."
|
||||||
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."
|
|
||||||
>
|
>
|
||||||
<Variant
|
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
|
||||||
letter="A"
|
<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">
|
||||||
name="Tap = show the controls; play is a button"
|
{/* The visible track stays thin; the dashed box is the 44-tall hit area around it. */}
|
||||||
recommended
|
<div
|
||||||
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."
|
className="relative flex h-11 items-center"
|
||||||
>
|
style={{ outline: "1px dashed rgb(255 255 255 / 0.35)" }}
|
||||||
<Player touch />
|
>
|
||||||
</Variant>
|
<div className="h-1 w-full rounded-full bg-white/30">
|
||||||
<Variant
|
<div className="h-1 w-2/5 rounded-full bg-accent" />
|
||||||
letter="B"
|
</div>
|
||||||
name="Today"
|
<span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" />
|
||||||
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."
|
</div>
|
||||||
>
|
<div className="flex items-center gap-1">
|
||||||
<Player touch={false} />
|
{[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
|
||||||
</Variant>
|
<span key={i} className={btn}>
|
||||||
</Decision>
|
<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 }) {
|
function C4() {
|
||||||
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>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="glass relative flex h-52 flex-col overflow-hidden rounded-2xl">
|
<Reference
|
||||||
<div
|
id="c4"
|
||||||
className={clsx(
|
code="C4"
|
||||||
"relative z-base flex items-center justify-between px-3 py-2 text-sm font-semibold",
|
title="Dialog conventions"
|
||||||
FADE_BELOW_PANEL,
|
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">
|
||||||
Edit details
|
<div
|
||||||
<span
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"grid place-items-center rounded-lg text-muted",
|
"relative z-base flex items-center justify-between px-3 py-2 text-sm font-semibold",
|
||||||
H.iconSm,
|
FADE_BELOW_PANEL,
|
||||||
!pinned && "ring-2 ring-accent",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X className={ico} />
|
Edit details
|
||||||
</span>
|
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm)}>
|
||||||
</div>
|
<X className={ico} />
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">{fields}</div>
|
</span>
|
||||||
{pinned && (
|
</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)}>
|
<div className={clsx("relative z-base flex justify-end gap-2 px-3 py-2", FADE_ABOVE_PANEL)}>
|
||||||
<Btn tone="ghost" size="md">
|
<Btn tone="ghost" size="md">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -235,34 +192,132 @@ function ShortDialog({ pinned }: { pinned: boolean }) {
|
|||||||
Save
|
Save
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</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>
|
</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 (
|
return (
|
||||||
<Decision
|
<Decision
|
||||||
id="c4"
|
id="c5"
|
||||||
code="C4"
|
code="C5"
|
||||||
title="Dialog conventions"
|
title="The Plex control row on a narrow screen"
|
||||||
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."
|
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="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."
|
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
|
<Variant
|
||||||
letter="A"
|
letter="A"
|
||||||
name="Focus by purpose; the footer is pinned"
|
name="Everyday controls stay, the rest go into ⋯ More"
|
||||||
recommended
|
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>
|
||||||
<Variant
|
<Variant
|
||||||
letter="B"
|
letter="B"
|
||||||
name="Focus on Close; the footer scrolls with the body"
|
name="The row wraps onto a second line"
|
||||||
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."
|
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>
|
</Variant>
|
||||||
</Decision>
|
</Decision>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Bookmark, Check, ListPlus, MoreHorizontal, Play } from "lucide-react";
|
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";
|
import { H, LAYER } from "./labTokens";
|
||||||
|
|
||||||
// E28 S2 · Batch C, part 1 — action placement: the classification table (object × action → where it
|
// 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
|
// 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
|
// 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.
|
// 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() {
|
export function C1() {
|
||||||
return (
|
return (
|
||||||
<Decision
|
<Reference
|
||||||
id="c1"
|
id="c1"
|
||||||
code="C1"
|
code="C1"
|
||||||
title="Action placement: the classification"
|
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."
|
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."
|
||||||
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."
|
|
||||||
>
|
>
|
||||||
<Variant
|
<div className="flex flex-wrap gap-4">
|
||||||
letter="A"
|
<Sample label="Card · mouse, hovered">
|
||||||
name="The card, mouse (hovered) and touch"
|
<Card touch={false} />
|
||||||
recommended
|
</Sample>
|
||||||
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."
|
<Sample label="Card · touch">
|
||||||
>
|
<Card touch />
|
||||||
<div className="flex flex-wrap gap-4">
|
</Sample>
|
||||||
<Sample label="Mouse, hovered">
|
<Sample label="The background menu (desktop only; it mirrors the toolbar, Shift+right-click = the browser's)">
|
||||||
<Card touch={false} />
|
<table className="text-xs">
|
||||||
</Sample>
|
<tbody>
|
||||||
<Sample label="Touch">
|
{BACKGROUND.map(([page, items]) => (
|
||||||
<Card touch />
|
<tr key={page} className="align-top [&>td]:py-1 [&>td]:pr-3">
|
||||||
</Sample>
|
<td className="whitespace-nowrap font-medium">{page}</td>
|
||||||
</div>
|
<td className="text-muted">{items}</td>
|
||||||
</Variant>
|
</tr>
|
||||||
<Variant
|
))}
|
||||||
letter="B"
|
</tbody>
|
||||||
name="The background menu (desktop only)"
|
</table>
|
||||||
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."
|
</Sample>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</Decision>
|
<Table />
|
||||||
|
</Reference>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user