design(files): the one-library round in the lab — aligned list columns, the tray, the done notice

The Files page replaces the Downloads page: list rows with one column per value kind and a
right-click menu, an app-wide download tray, a done notice that copies the link. Picked
variants are logged in DESIGN.md.
This commit is contained in:
2026-10-11 02:59:51 +02:00
parent e2ed2bf874
commit 07e0990ecf
4 changed files with 819 additions and 3 deletions
+4
View File
@@ -774,3 +774,7 @@ of a full-width panel and nothing on the right.
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
+11
View File
@@ -4,6 +4,7 @@ import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { FilesRound } from "./filesRound";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
@@ -74,6 +75,9 @@ export default function DesignLab() {
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
Files
</a>
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
A ✓
</a>
@@ -97,6 +101,13 @@ export default function DesignLab() {
Resize the window to tablet size to see them there too.
</p>
</header>
<header id="open-files" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
<p className="text-sm text-muted">
The page that replaces Downloads. The recommended variant has the accent edge.
</p>
</header>
<FilesRound />
<header id="decided" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · batch A, controls</h2>
<p className="text-sm text-muted">
+730
View File
@@ -0,0 +1,730 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
Clock,
Copy,
Download,
FileText,
Film,
Folder,
FolderInput,
FolderPlus,
Globe,
HardDrive,
LayoutGrid,
Link,
List,
Loader2,
MoreHorizontal,
Pencil,
Pin,
Play,
RotateCw,
Scissors,
Search,
Server,
Share2,
Smartphone,
Trash2,
Upload,
Users,
X,
} from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { Decision, Sample, Variant } from "./labKit";
import { Badge } from "../components/ui/Badge";
import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { fieldClass } from "../components/ui/fieldStyles";
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
// The Files round: the one library that replaces the Downloads page. Static markup on purpose (the
// lab's rule): the sample rows are the owner's own kind of content, nothing here talks to the server.
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
type Source = "youtube" | "plex" | "site" | "upload" | "hub";
const SOURCE_ICON: Record<Source, typeof Film | typeof YouTubeIcon> = {
youtube: YouTubeIcon,
plex: Film,
site: Globe,
upload: Upload,
hub: Server,
};
type Item = {
name: string;
source: Source;
sourceLabel: string;
by?: string;
kind: "video" | "text" | "pdf";
size: string;
length?: string;
when: string;
state: "phone" | "converting" | "file";
retention?: { days: number } | "kept";
clip?: boolean;
hue: string;
};
const ITEMS: Item[] = [
{
name: "Westworld — S1·E6 — The Adversary",
source: "plex",
sourceLabel: "Plex",
by: "Westworld",
kind: "video",
size: "12,2 MB",
length: "1:03",
when: "13 órája",
state: "phone",
retention: { days: 29 },
clip: true,
hue: "from-rating/45",
},
{
name: "Első benyomások | Rainbow Six Mobile (iPhone 13)",
source: "youtube",
sourceLabel: "YouTube",
by: "Yeahunter",
kind: "video",
size: "2,0 MB",
length: "0:30",
when: "13 órája",
state: "phone",
retention: { days: 29 },
clip: true,
hue: "from-accent/45",
},
{
name: "Szméagol szompolyog",
source: "youtube",
sourceLabel: "YouTube",
by: "R Máté",
kind: "video",
size: "4,8 MB",
length: "1:56",
when: "2 napja",
state: "phone",
retention: { days: 4 },
hue: "from-success/40",
},
{
name: "Ez a nap is eljött",
source: "site",
sourceLabel: "tiktok.com",
by: "@gasparimre",
kind: "video",
size: "38,5 MB",
length: "0:58",
when: "most",
state: "converting",
retention: { days: 30 },
hue: "from-danger/35",
},
{
name: "jelentes.md",
source: "hub",
sourceLabel: "Hub",
kind: "text",
size: "4 KB",
when: "1 órája",
state: "file",
hue: "from-fg/15",
},
{
name: "szerzodes.pdf",
source: "upload",
sourceLabel: "Feltöltés",
kind: "pdf",
size: "1,1 MB",
when: "tegnap",
state: "file",
retention: "kept",
hue: "from-warn/35",
},
];
function SourceBadge({ item }: { item: Item }) {
const Icon = SOURCE_ICON[item.source];
return (
<Badge>
<Icon />
{item.sourceLabel}
</Badge>
);
}
function StateBadges({ item }: { item: Item }) {
return (
<>
{item.state === "phone" && (
<Badge title="Telefonon is lejátszható">
<Smartphone />
<span className="text-success">✓</span>
</Badge>
)}
{item.state === "converting" && (
<Badge tone="accent">
<Loader2 className="animate-spin" />
Telefonra alakítás…
</Badge>
)}
{item.retention === "kept" && (
<Badge>
<Pin />
Megtartva
</Badge>
)}
{item.retention && item.retention !== "kept" && item.retention.days <= 7 && (
<Badge tone="warn">
<Clock />
Lejár {item.retention.days} nap múlva
</Badge>
)}
</>
);
}
function Thumb({
item,
className,
clipOnPicture = false,
}: {
item: Item;
className: string;
/** A grid card marks a clip on its picture; a small list thumb has no room beside the length. */
clipOnPicture?: boolean;
}) {
const Glyph = item.kind === "video" ? null : FileText;
return (
<div
className={clsx(
"relative shrink-0 overflow-hidden rounded-lg bg-linear-to-br to-card",
item.hue,
className,
)}
>
{Glyph && <Glyph className="absolute inset-0 m-auto size-6 text-muted" />}
{item.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{item.length}</span>}
{clipOnPicture && item.clip && (
<span className={clsx(MEDIA_BADGE, "left-1 top-1 inline-flex items-center gap-0.5")}>
<Scissors className="size-3" />
klip
</span>
)}
</div>
);
}
function RowActions({ item }: { item: Item }) {
return (
<span className="flex shrink-0 items-center gap-0.5">
{item.kind === "video" ? (
<span className={ICON} title="Link másolása">
<Link />
</span>
) : (
<span className="size-7" aria-hidden />
)}
<span className={ICON} title="Megosztás">
<Share2 />
</span>
<span className={ICON} title="Letöltés">
<Download />
</span>
<span className={ICON} title="Továbbiak">
<MoreHorizontal />
</span>
</span>
);
}
// ── The page frame: sidebar + toolbar, shared by both layouts ────────────────────────────────────
function SideEntry({
icon: Icon,
label,
count,
active = false,
}: {
icon: typeof Film | typeof YouTubeIcon;
label: string;
count?: number;
active?: boolean;
}) {
return (
<span
className={clsx(
"flex h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-accent/12 font-medium text-accent" : "text-muted",
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1 truncate">{label}</span>
{count !== undefined && <span className="text-xs tabular-nums">{count}</span>}
</span>
);
}
function Sidebar() {
return (
<aside className="flex w-52 shrink-0 flex-col gap-3">
<div className="flex flex-col gap-0.5">
<SideEntry icon={HardDrive} label="Minden fájl" count={26} active />
<SideEntry icon={Users} label="Megosztva velem" count={3} />
</div>
<div className="flex flex-col gap-0.5">
<span className="px-2 text-2xs uppercase tracking-wider text-muted">Források</span>
<SideEntry icon={YouTubeIcon} label="YouTube" count={12} />
<SideEntry icon={Film} label="Plex klipek" count={4} />
<SideEntry icon={Globe} label="tiktok.com" count={1} />
<SideEntry icon={Upload} label="Feltöltések" count={2} />
<SideEntry icon={Server} label="Hub" count={3} />
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center px-2 text-2xs uppercase tracking-wider text-muted">
<span className="flex-1">Saját mappák</span>
<FolderPlus className="size-3.5" />
</span>
<SideEntry icon={Folder} label="Cimboráknak" count={3} />
<SideEntry icon={Folder} label="Receptek" count={1} />
</div>
<div className="mt-auto flex flex-col gap-1 px-2 text-xs text-muted">
<span className="tabular-nums">228 MB / 5 GB</span>
<span className="h-1 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full w-[5%] rounded-full bg-accent" />
</span>
</div>
</aside>
);
}
function Toolbar({ grid }: { grid: boolean }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className={clsx(fieldClass({ size: "sm" }), "flex min-w-64 flex-1 items-center gap-2")}>
<Download className="size-4 text-muted" />
<span className="flex-1 truncate text-muted">
Illessz be egy linket, és letöltöm (YouTube, TikTok, Facebook…)
</span>
</span>
<span className={buttonClass({ variant: "primary", size: "sm" })}>Letöltés</span>
<span className="mx-1 h-5 w-px bg-border" />
<span
className={clsx(fieldClass({ size: "sm", pill: true }), "flex w-44 items-center gap-2")}
>
<Search className="size-4 text-muted" />
<span className="text-muted">Keresés</span>
</span>
<span className={buttonClass({ variant: "outline", size: "sm" })}>
<Upload className="size-4" />
Feltöltés
</span>
<span className="flex">
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={!grid}>
<List />
</span>
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={grid}>
<LayoutGrid />
</span>
</span>
</div>
);
}
function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
return (
<div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex items-baseline gap-2">
<h4 className="text-lg font-bold">Minden fájl</h4>
<span className="text-xs text-muted">26 elem</span>
<span className="ml-auto flex items-center gap-1 text-xs text-muted">
Legújabb elöl <ChevronDown className="size-3.5" />
</span>
</div>
<Toolbar grid={grid} />
{children}
</div>
</div>
);
}
// ── F1: the list and the grid ────────────────────────────────────────────────────────────────────
// One grid for the whole list, each row a subgrid: every kind of value sits in its own column, so the
// badges, the sizes and the action icons line up down the page. An action that does not apply keeps
// its slot empty (a file has no "copy the play link").
const LIST_COLUMNS =
"grid grid-cols-[4rem_minmax(0,1fr)_7rem_6.5rem_7.5rem_4.5rem_4.5rem_auto] items-center gap-x-3";
const ROW = "col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5";
function ListHead() {
const th = "text-2xs uppercase tracking-wider text-muted";
return (
<div className={clsx(ROW, "py-1")}>
<span />
<span className={th}>Név</span>
<span className={th}>Forrás</span>
<span className={th}>Telefon</span>
<span className={th}>Megőrzés</span>
<span className={clsx(th, "text-right")}>Méret</span>
<span className={clsx(th, "text-right")}>Hozzáadva</span>
<span />
</div>
);
}
function PhoneCell({ item }: { item: Item }) {
if (item.state === "phone")
return (
<Badge title="Telefonon is lejátszható">
<Smartphone />
<span className="text-success">✓</span>
</Badge>
);
if (item.state === "converting")
return (
<Badge tone="accent">
<Loader2 className="animate-spin" />
Alakítás…
</Badge>
);
return <span className="text-xs text-muted">—</span>;
}
function KeepCell({ item }: { item: Item }) {
if (item.retention === "kept")
return (
<Badge>
<Pin />
Megtartva
</Badge>
);
if (!item.retention) return <span className="text-xs text-muted">—</span>;
return item.retention.days <= 7 ? (
<Badge tone="warn">
<Clock />
{item.retention.days} nap
</Badge>
) : (
<span className="text-xs tabular-nums text-muted">{item.retention.days} nap</span>
);
}
function ListRows() {
return (
<div className={LIST_COLUMNS}>
<ListHead />
{ITEMS.map((item, i) => (
<div key={item.name} className={clsx(ROW, i === 3 && "bg-accent/8 ring-1 ring-accent/40")}>
<Thumb item={item} className="h-10 w-16" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.name}</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span className="truncate">{item.by ?? "—"}</span>
{item.clip && (
<Badge>
<Scissors />
klip
</Badge>
)}
</span>
</div>
<span>
<SourceBadge item={item} />
</span>
<span>
<PhoneCell item={item} />
</span>
<span>
<KeepCell item={item} />
</span>
<span className="text-right text-xs tabular-nums text-muted">{item.size}</span>
<span className="text-right text-xs text-muted">{item.when}</span>
<RowActions item={item} />
</div>
))}
</div>
);
}
// The right-click menu of a row: every action of the item, the row icons' ones first.
function RowMenu() {
const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm";
const groups: [typeof Film, string, boolean?][][] = [
[
[Play, "Lejátszás"],
[Link, "Link másolása"],
[Share2, "Megosztás…"],
[Download, "Letöltés"],
],
[
[Scissors, "Klip készítése"],
[Pencil, "Átnevezés"],
[FolderInput, "Áthelyezés…"],
[Pin, "Megtartom"],
],
[[Trash2, "Törlés", true]],
];
return (
<div className={clsx(SURFACE.menu, "w-56 shadow-float")}>
{groups.map((g, gi) => (
<div key={gi}>
{gi > 0 && <div className="my-1 h-px bg-border" />}
{g.map(([Icon, label, danger]) => (
<span
key={label}
className={clsx(
row,
danger ? "text-danger" : "",
label === "Link másolása" && "bg-fg/8",
)}
>
<Icon className={clsx("size-4 shrink-0", danger ? "text-danger" : "text-muted")} />
{label}
</span>
))}
</div>
))}
</div>
);
}
function GridCards() {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
{ITEMS.map((item, i) => (
<div
key={item.name}
className={clsx(
"flex flex-col gap-1.5 rounded-xl p-1.5",
i === 3 && "bg-accent/8 ring-1 ring-accent/40",
)}
>
<Thumb item={item} className="aspect-video w-full" clipOnPicture />
<span className="line-clamp-2 text-sm font-medium leading-5">{item.name}</span>
<span className="flex flex-wrap items-center gap-1 text-xs text-muted">
<SourceBadge item={item} />
{item.by && <span className="truncate">{item.by}</span>}
</span>
<span className="flex flex-wrap items-center gap-1">
<StateBadges item={item} />
</span>
</div>
))}
</div>
);
}
// ── F2: the download tray ────────────────────────────────────────────────────────────────────────
function TrayRow({ title, site, children }: { title: string; site: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1 px-3 py-2">
<span className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm">{title}</span>
<span className="text-xs text-muted">{site}</span>
</span>
{children}
</div>
);
}
function Progress({ pct }: { pct: number }) {
return (
<span className="flex items-center gap-2 text-xs text-muted">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</span>
<span className="w-8 text-right tabular-nums">{pct}%</span>
</span>
);
}
function TrayPanel() {
return (
<div className={clsx(SURFACE.popover, "w-80 max-w-full overflow-hidden shadow-float")}>
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
<Loader2 className="size-4 animate-spin text-accent" />
<span className="flex-1 text-sm font-semibold">Letöltések · 2 fut, 1 hibás</span>
<span className={ICON}>
<ChevronDown />
</span>
<span className={ICON}>
<X />
</span>
</div>
<TrayRow title="Ez a nap is eljött" site="tiktok.com">
<Progress pct={64} />
</TrayRow>
<TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube">
<span className="text-xs text-muted">Sorban áll…</span>
</TrayRow>
<TrayRow title="chatgptmagyarul/posts/2564…" site="facebook.com">
<span className="flex items-center gap-2 text-xs">
<AlertTriangle className="size-3.5 text-danger" />
<span className="flex-1 text-danger">Ebben a posztban nincs videó</span>
<span className={buttonClass({ variant: "link", size: "none" })}>
<RotateCw className="size-3" />
Újra
</span>
</span>
</TrayRow>
<TrayRow title="Szméagol szompolyog" site="YouTube">
<span className="flex items-center gap-2 text-xs">
<CheckCircle2 className="size-3.5 text-success" />
<span className="flex-1 text-success">Kész</span>
<span className={buttonClass({ variant: "link", size: "none" })}>Megnyitás</span>
</span>
</TrayRow>
</div>
);
}
function TrayPill() {
return (
<span
className={clsx(
SURFACE.floating,
"inline-flex items-center gap-2 self-start px-3 py-1.5 text-sm",
)}
>
<Loader2 className="size-4 animate-spin text-accent" />2 letöltés fut
<span className="h-1 w-16 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full w-2/3 rounded-full bg-accent" />
</span>
</span>
);
}
function RailBell() {
return (
<div className="flex items-start gap-3">
<span className="relative grid size-10 place-items-center rounded-xl bg-accent/12 text-accent">
<Download className="size-5" />
<span className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full bg-accent text-2xs font-bold text-accent-fg">
3
</span>
</span>
<TrayPanel />
</div>
);
}
// ── F3: the done notification ────────────────────────────────────────────────────────────────────
function DoneToast() {
return (
<div className={clsx(SURFACE.popover, "toast-card flex w-80 max-w-full items-start gap-3 p-3")}>
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-success" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-sm font-semibold">Letöltve: Szméagol szompolyog</span>
<span className="flex items-center gap-1.5 text-xs text-muted">
<Smartphone className="size-3.5" />
Telefonon is lejátszható <span className="text-success">✓</span>
</span>
<span className="mt-1 flex gap-2">
<span className={buttonClass({ variant: "primary", size: "sm" })}>
<Copy className="size-4" />
Link másolása
</span>
<span className={buttonClass({ variant: "outline", size: "sm" })}>Megnyitás</span>
</span>
</div>
<span className={ICON}>
<X />
</span>
</div>
);
}
export function FilesRound() {
return (
<div className="flex flex-col gap-4">
<Decision
id="files-layout"
code="F1"
title="The Files page"
question="One library for everything: how does a folder show its items? Both have the same sidebar (sources, own folders, shared with me, storage), the paste box and the same badges: source + channel, phone-safe ✓, expiry when ≤7 days, kept. The highlighted item is the one just downloaded (focus on arrival)."
now="Downloads › Library: one tall card per item, no source, no folders, a row of three icons; the paste box lives on the Queue tab."
>
<Variant
letter="A"
name="List rows (grid one click away)"
recommended
why="A library holds videos AND files (a hub report, a PDF): rows read both, show 12–15 items a screen, and put size and date in columns you can scan. The grid stays a toggle, remembered per folder, for a folder of clips."
>
<Frame grid={false}>
<ListRows />
</Frame>
<Sample label="Right click on a row (the ⋯ opens the same menu)">
<RowMenu />
</Sample>
</Variant>
<Variant
letter="B"
name="Media grid (list one click away)"
why="Pictures first, like the feed: best for a folder of clips, but a text file or a PDF becomes a big grey card, and 6–8 items fit a screen."
>
<Frame grid>
<GridCards />
</Frame>
</Variant>
</Decision>
<Decision
id="files-tray"
code="F2"
title="Running downloads"
question="Downloading is an action now, so where do the running and stuck ones show — on every page, not only in Files?"
now="the Queue tab of the Downloads page: you have to go there to see it."
>
<div className="grid grid-cols-[repeat(auto-fit,minmax(22rem,1fr))] gap-4">
<Variant
letter="A"
name="A tray, bottom right (Drive)"
recommended
why="It appears by itself while something runs and goes when all is done; a failure stays with its reason and a retry. Folds to a pill, so it never covers a player control."
>
<Sample label="Open">
<TrayPanel />
</Sample>
<Sample label="Folded">
<TrayPill />
</Sample>
</Variant>
<Variant
letter="B"
name="A rail icon with a count, the list in a popover"
why="Nothing floats over the page, but a running download is invisible until you look, and the rail grows a sixth always-there icon."
>
<RailBell />
</Variant>
</div>
</Decision>
<Decision
id="files-done"
code="F3"
title="When a download is done"
question="The daily path is paste → done → copy the link → send. Can the notification finish it?"
now="a generic 'ready' notification; you open Library, check it plays, then press the share icon."
>
<Variant
letter="A"
name="The notification carries the link"
recommended
why="It says whether it plays on a phone and copies the link in one click; Megnyitás opens its folder with the item focused. No jump on its own — it would pull you out of what you were doing."
>
<DoneToast />
</Variant>
</Decision>
</div>
);
}
+74 -3
View File
@@ -1,10 +1,11 @@
import { useSyncExternalStore, type ReactNode } from "react";
import clsx from "clsx";
import { Badge } from "../components/ui/Badge";
import { SURFACE } from "../components/ui/surfaces";
// Building blocks of the design lab. Every decision is taken, so the lab is the reference half
// only: each <Reference> is the chosen rendering with the rule it became. A new design round brings
// back the side-by-side <Decision>/<Variant> pair (as before) for its open questions. Everything
// Building blocks of the design lab. A taken decision is a <Reference>: the chosen rendering with the
// rule it became. An open design round shows its questions as <Decision>s with their <Variant>s side
// by side, until the owner picks. Everything
// is static markup on purpose: a rendering that only exists here must not need app state.
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
@@ -36,6 +37,76 @@ export function Reference({
);
}
/** An open question of a design round: what the app does today, then the variants side by side. */
export function Decision({
id,
code,
title,
question,
now,
children,
}: {
id: string;
code: string;
title: string;
question: string;
/** What the app does today. */
now: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className={`${SURFACE.panel} p-5 scroll-mt-20`}>
<header className="mb-4">
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
<h3 className="text-lg font-bold">{title}</h3>
</div>
<p className="mt-1 text-sm">{question}</p>
<p className="mt-1 text-xs text-muted">
<span className="font-semibold">Today: </span>
{now}
</p>
</header>
<div className="flex flex-col gap-4">{children}</div>
</section>
);
}
/** One answer to a Decision; the recommended one carries the accent edge. */
export function Variant({
letter,
name,
recommended = false,
why,
children,
}: {
letter: string;
name: string;
recommended?: boolean;
why: string;
children: ReactNode;
}) {
return (
<article
className={clsx(
SURFACE.card,
"flex flex-col gap-3 border p-4",
recommended ? "border-accent" : "border-transparent",
)}
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-fg/8 text-xs font-bold">
{letter}
</span>
<h4 className="flex-1 text-sm font-semibold">{name}</h4>
{recommended && <Badge tone="accent">Recommended</Badge>}
</div>
<div className="flex flex-col gap-3">{children}</div>
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
</article>
);
}
/** A small caption above a sample inside a variant. */
export function Sample({ label, children }: { label: string; children: ReactNode }) {
return (