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
+416
View File
@@ -0,0 +1,416 @@
import clsx from "clsx";
import { Bookmark, Check, ListPlus, MoreHorizontal, Play } from "lucide-react";
import { Decision, Sample, Variant } 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
// 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.
type Where = "rest" | "hover" | "menu";
type Row = {
action: string;
/** mouse: visible at rest / on hover / only in the menu (the menu always lists everything) */
mouse: Where;
/** touch card / row: visible, or reached through "…" */
touch: "visible" | "…";
/** what changes against today (the S2 inventory), empty when nothing does */
change?: string;
danger?: boolean;
};
type Group = { object: string; rows: Row[] };
const TABLE: Group[] = [
{
object: "Video card (feed grid and list rows, Home shelves)",
rows: [
{
action: "Play / continue",
mouse: "rest",
touch: "visible",
change:
"the thumbnail plays; the title is a link-styled element that ignores a click ending a text selection",
},
{ action: "Open channel", mouse: "rest", touch: "visible" },
{
action: "Watched (toggle)",
mouse: "hover",
touch: "…",
change: "the watched / progress STATE stays visible at rest",
},
{ action: "Save (watch later)", mouse: "hover", touch: "…" },
{
action: "Add to playlist",
mouse: "hover",
touch: "…",
change: "today hover only, not in the menu",
},
{
action: "Download",
mouse: "menu",
touch: "…",
change: "today hover only, not in the menu",
},
{
action: "Details (ⓘ)",
mouse: "menu",
touch: "…",
change: "today hover only, gone on touch",
},
{
action: "Hide",
mouse: "menu",
touch: "…",
change: "leaves the hover bar (undo via the toast)",
},
{ action: "Reset state", mouse: "menu", touch: "…", change: "leaves the hover bar" },
{ action: "Pin to shelf", mouse: "menu", touch: "…" },
{
action: "Copy title / Copy link",
mouse: "menu",
touch: "…",
change: "new (text stays selectable)",
},
],
},
{
object: "Playlist item row",
rows: [
{ action: "Play", mouse: "rest", touch: "visible" },
{
action: "Reorder (drag grip)",
mouse: "rest",
touch: "visible",
change: "the grip gets touch-action: none",
},
{ action: "Move top / up / down / bottom", mouse: "menu", touch: "…" },
{ action: "Move to… / to a recent playlist", mouse: "menu", touch: "…" },
{ action: "Pin", mouse: "menu", touch: "…" },
{ action: "Copy title / Copy link", mouse: "menu", touch: "…", change: "new" },
{
action: "Remove from playlist",
mouse: "menu",
touch: "…",
danger: true,
change: "the X column goes; Delete key + undo toast",
},
],
},
{
object: "Playlist (the rail row and the page header)",
rows: [
{ action: "Open", mouse: "rest", touch: "visible" },
{ action: "Play all", mouse: "rest", touch: "visible", change: "header" },
{
action: "Rename",
mouse: "menu",
touch: "…",
change: "the header pencil becomes a menu item",
},
{ action: "Pin", mouse: "menu", touch: "…" },
{
action: "Open on YouTube / Push to YouTube",
mouse: "menu",
touch: "…",
change: "from the header into the menu",
},
{ action: "Revert to YouTube", mouse: "menu", touch: "…", danger: true },
{
action: "Delete",
mouse: "menu",
touch: "…",
danger: true,
change: "the header trash goes into the menu",
},
],
},
{
object: "Channel row (Channels manager)",
rows: [
{ action: "Open the channel", mouse: "rest", touch: "visible" },
{
action: "Priority (stepper)",
mouse: "rest",
touch: "visible",
change: "it is a value, so it stays",
},
{ action: "Tags", mouse: "rest", touch: "visible", change: "a value, stays" },
{
action: "Hidden from feed (state)",
mouse: "rest",
touch: "visible",
change: "a badge; the toggle moves to the menu",
},
{
action: "Hide / unhide, Backfill",
mouse: "menu",
touch: "…",
change: "from row buttons into the menu",
},
{ action: "Pin, Copy name / link", mouse: "menu", touch: "…" },
{
action: "Reset backfill (admin), Unsubscribe",
mouse: "menu",
touch: "…",
danger: true,
change: "from row buttons into the menu",
},
],
},
{
object: "Plex card (movie, show, season, episode)",
rows: [
{ action: "Open / play", mouse: "rest", touch: "visible" },
{
action: "Watched (state)",
mouse: "rest",
touch: "visible",
change: "the badge stays; the toggle is hover + menu",
},
{
action: "Watched (toggle)",
mouse: "hover",
touch: "…",
change: "today hover only, not in the menu",
},
{
action: "Add to playlist",
mouse: "hover",
touch: "…",
change: "today hover only on seasons",
},
{ action: "Details", mouse: "menu", touch: "…", change: "today hover only" },
{ action: "Pin", mouse: "menu", touch: "…" },
],
},
{
object: "Download row",
rows: [
{ action: "Play (library, shared)", mouse: "rest", touch: "visible" },
{
action: "Pause / resume / retry (queue)",
mouse: "rest",
touch: "visible",
change: "the queue's primary action",
},
{ action: "Save file", mouse: "rest", touch: "visible" },
{ action: "Error details (state)", mouse: "rest", touch: "visible" },
{
action: "Edit clip, Edit details, Share, Manage shares, Redownload, Copy source URL",
mouse: "menu",
touch: "…",
change: "six row buttons become one '…'; a menu is new here",
},
{
action: "Cancel, Delete, Remove shared",
mouse: "menu",
touch: "…",
danger: true,
change: "from row buttons into the menu",
},
],
},
{
object: "Notification row",
rows: [
{
action: "Its link (open Downloads / Scheduler, review access…)",
mouse: "rest",
touch: "visible",
},
{ action: "Unread (state), Mark read", mouse: "rest", touch: "visible" },
{
action: "Revert / find (activity rows)",
mouse: "menu",
touch: "…",
change: "a menu is new here",
},
{ action: "Dismiss", mouse: "hover", touch: "…" },
{ action: "Header: Mark all read", mouse: "rest", touch: "visible" },
{
action: "Header: Clear everything",
mouse: "menu",
touch: "…",
danger: true,
change: "into the header's '…'",
},
],
},
{
object: "Scheduler job row and page",
rows: [
{ action: "Run now", mouse: "rest", touch: "visible" },
{ action: "Interval (value, click to edit)", mouse: "rest", touch: "visible" },
{ action: "Page: Run all, Pause / resume", mouse: "rest", touch: "visible" },
{
action: "Page: Purge discovery",
mouse: "menu",
touch: "…",
danger: true,
change: "into the page's '…'",
},
],
},
];
const BACKGROUND: [string, string][] = [
["Feed", "Sort · View (grid / list) · Watch state · Shorts on / off"],
["Plex browse", "Sort · View"],
["Channels", "Sort · Sync subscriptions · Backfill everything"],
["Playlists", "New playlist · Sync with YouTube"],
["Downloads", "Queue / Library / Shared (the tabs)"],
["Notifications", "Mark all read"],
];
const MOUSE: Record<Where, string> = {
rest: "● at rest",
hover: "◐ on hover",
menu: "☰ menu",
};
function Table() {
return (
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-left text-xs">
<thead className="text-muted">
<tr className="[&>th]:px-2 [&>th]:py-1.5 [&>th]:font-medium">
<th>Action</th>
<th>Mouse</th>
<th>Touch</th>
<th>Change against today</th>
</tr>
</thead>
{TABLE.map((g) => (
<tbody key={g.object} className="border-t border-border/60">
<tr>
<th colSpan={4} className="bg-card/60 px-2 py-1.5 font-semibold">
{g.object}
</th>
</tr>
{g.rows.map((r) => (
<tr key={r.action} className="[&>td]:px-2 [&>td]:py-1 align-top">
<td className={clsx(r.danger && "text-danger")}>
{r.action}
{r.danger && " ⚠"}
</td>
<td className="whitespace-nowrap">{MOUSE[r.mouse]}</td>
<td className="whitespace-nowrap">
{r.touch === "visible" ? "● visible" : "… menu"}
</td>
<td className="text-muted">{r.change}</td>
</tr>
))}
</tbody>
))}
</table>
</div>
);
}
function Card({ touch }: { touch: boolean }) {
const act = clsx(
"grid place-items-center rounded-lg bg-black/55 text-white",
touch ? "h-11 w-11" : H.iconSm,
);
return (
<div className="glass-card group flex w-60 flex-col gap-1.5 rounded-xl p-1.5">
<div className="relative aspect-video overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
<span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<span className="block h-1 w-1/3 bg-accent" />
</span>
<span className="absolute left-1.5 top-1.5 rounded-full bg-black/60 px-1.5 py-0.5 text-3xs text-white">
12:04 left
</span>
{!touch && (
<span className="absolute bottom-2 right-1.5 flex gap-1">
<span className={act}>
<Check className="h-3.5 w-3.5" />
</span>
<span className={act}>
<Bookmark className="h-3.5 w-3.5" />
</span>
<span className={act}>
<ListPlus className="h-3.5 w-3.5" />
</span>
<span className={act}>
<MoreHorizontal className="h-3.5 w-3.5" />
</span>
</span>
)}
<span className="absolute inset-0 grid place-items-center">
<span className="grid h-10 w-10 place-items-center rounded-full bg-black/55 text-white">
<Play className="h-5 w-5" />
</span>
</span>
</div>
<div className="flex items-start gap-1">
<div className="min-w-0 flex-1 px-0.5">
<div className="line-clamp-2 select-text text-xs font-medium">
Building a home lab on a budget: Proxmox, ZFS and a cheap NAS
</div>
<div className="text-2xs text-muted">Techno Tim · 2 days ago</div>
</div>
{touch && (
<span
className={clsx(
"grid shrink-0 place-items-center rounded-lg text-muted",
"h-11 w-11",
LAYER,
)}
>
<MoreHorizontal className="h-4 w-4" />
</span>
)}
</div>
</div>
);
}
export function C1() {
return (
<Decision
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."
>
<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>
</Decision>
);
}