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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user