feat(design): batch B recorded, batch C in the design lab (E28 S2c)

DESIGN.md: B1-B9 decided (compact density, the four-scheme set with the
accent-vs-status rule, state layers, the sticky band, surface per role, the
watch page, the slim NavRail with optional labels, loading by shape, the
fade edges) with their decision-log rows. The lab keeps the chosen A and B
renderings as reference on shared tokens (labTokens.ts) and adds batch C:
the action-placement classification for review, side panels on touch and
below 768, the Plex player on touch, and the dialog conventions.
This commit is contained in:
2026-09-24 03:10:00 +02:00
parent 477271907e
commit 7055548260
8 changed files with 1403 additions and 1445 deletions
+269
View File
@@ -0,0 +1,269 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import { Maximize, Pause, Pin, SkipForward, Subtitles, Volume2, X } from "lucide-react";
import { Btn } from "./batchA";
import { Decision, 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.
const ico = "w-4 h-4";
function Screen({ label, children }: { label: string; children: ReactNode }) {
return (
<Sample label={label}>
<div className="relative h-56 overflow-hidden rounded-xl border border-border bg-bg">
{children}
</div>
</Sample>
);
}
function Content({ cols = 3 }: { cols?: number }) {
return (
<div
className="grid gap-2 p-3"
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 className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
<span className="truncate text-2xs">Video {i + 1}</span>
</div>
))}
</div>
);
}
function FilterPanel({ pinnable }: { pinnable?: boolean }) {
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)}>
<X className="h-3.5 w-3.5" />
</span>
</span>
</div>
{["Unwatched", "Shorts hidden", "Last 30 days", "Tech", "Science"].map((f) => (
<span key={f} className="chip px-2 py-1">
{f}
</span>
))}
</div>
);
}
// ── C2 · side panels on touch and on a narrow window ─────────────────────────────────────────────
export function C2() {
return (
<Decision
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."
>
<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>
);
}
// ── C3 · the player on touch ─────────────────────────────────────────────────────────────────────
function Player({ touch }: { touch: boolean }) {
const btn = touch ? "h-11 w-11" : "h-8 w-8";
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
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."
>
<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>
);
}
// ── C4 · dialog conventions ──────────────────────────────────────────────────────────────────────
function Field({ label, value, focused }: { label: string; value: string; focused?: boolean }) {
return (
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium">{label}</span>
<span
className={clsx(
"flex items-center rounded-lg border px-3 text-sm",
H.field,
focused && "ring-2 ring-accent",
)}
style={{ borderColor: STRONG }}
>
{value}
</span>
</label>
);
}
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>
);
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
className={clsx(
"grid place-items-center rounded-lg text-muted",
H.iconSm,
!pinned && "ring-2 ring-accent",
)}
>
<X className={ico} />
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">{fields}</div>
{pinned && (
<div className={clsx("relative z-base flex justify-end gap-2 px-3 py-2", FADE_ABOVE_PANEL)}>
<Btn tone="ghost" size="md">
Cancel
</Btn>
<Btn tone="primary" size="md">
Save
</Btn>
</div>
)}
</div>
);
}
export function C4() {
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."
>
<Variant
letter="A"
name="Focus by purpose; the footer is pinned"
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."
>
<ShortDialog pinned />
</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."
>
<ShortDialog pinned={false} />
</Variant>
</Decision>
);
}