Files
siftlode/frontend/src/design-lab/batchC.tsx
T
peter 89e65ca331 feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.

A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
2026-09-27 02:12:54 +02:00

379 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import clsx from "clsx";
import type { ReactNode } from "react";
import {
Activity,
Download,
FastForward,
Info,
Keyboard,
Maximize,
MoreHorizontal,
Pause,
Play,
Pin,
Rewind,
RotateCcw,
Settings,
SkipBack,
SkipForward,
Subtitles,
Volume2,
X,
} from "lucide-react";
import { Btn } from "./batchA";
import { C1 } from "./batchCActions";
import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, LAYER } from "./labTokens";
import { SURFACE } from "../components/ui/surfaces";
import { iconButtonClass } from "../components/ui/buttonStyles";
import { fieldClass } from "../components/ui/fieldStyles";
// The ui/ primitives' own classes on static spans: the lab draws what the app ships, not a copy.
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
// E28 S2 · Batch C — behaviour on touch and on a narrow window, DECIDED (owner, 2026-09-24): C1–C4 as
// proposed; C5 is the owner's reworked Plex control set (found by the live player pass, E28 S2d: the
// old row ran off-screen below 1024) with the two refinements they accepted. References only.
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() {
return (
<div className="grid grid-cols-3 gap-2 p-3">
{Array.from({ length: 9 }, (_, i) => (
<div key={i} className={`${SURFACE.card} flex flex-col gap-1 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() {
return (
<div className={`${SURFACE.panel} flex h-full w-44 flex-col gap-2 p-3 text-xs`}>
<div className="flex items-center justify-between">
<span className="font-semibold">Filters</span>
<span className="flex gap-0.5">
<span className={ICON}>
<Pin className="h-3.5 w-3.5" />
</span>
<span className={ICON}>
<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 ─────────────────────────────────────────────
function C2() {
return (
<Reference
id="c2"
code="C2"
title="Side panels on touch and on a narrow window"
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."
>
<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 />
</div>
</Screen>
</Reference>
);
}
// ── C3 · the players on touch ────────────────────────────────────────────────────────────────────
function C3() {
const btn = "grid h-11 w-11 place-items-center rounded-full";
return (
<Reference
id="c3"
code="C3"
title="The players on touch"
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."
>
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-white/25 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 visible track stays thin; the dashed box is the 44-tall hit area around it. */}
<div
className="relative flex h-11 items-center"
style={{ outline: "1px dashed rgb(255 255 255 / 0.35)" }}
>
<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>
<div className="flex items-center gap-1">
{[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
<span key={i} className={btn}>
<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>
);
}
// ── 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(
fieldClass({ size: "md" }),
"flex items-center",
// The primitive's focus-within look, drawn: a mock span can hold no focus.
focused && "border-accent ring-1 ring-accent",
)}
>
{value}
</span>
</label>
);
}
function C4() {
return (
<Reference
id="c4"
code="C4"
title="Dialog conventions"
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={`${SURFACE.panel} relative flex h-52 flex-col overflow-hidden panel-scroll-scope`}
>
<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={ICON}>
<X className={ico} />
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar panel-scroller">
<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)}>
<Btn tone="ghost" size="md">
Cancel
</Btn>
<Btn tone="primary" size="md">
Save
</Btn>
</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>
<C5 />
</div>
);
}
// ── C5 · the Plex player's controls (the owner's model, 2026-09-24) ──────────────────────────────
// What is left after Stop, Restart and ±10 s leave the row fits a portrait tablet in one row, with a
// fixed "⋯" for the rare tools; restarting moves onto the card.
const ROW: { icon: typeof Pause; label: string }[] = [
{ icon: Pause, label: "Play / pause" },
{ icon: SkipBack, label: "Back: restart, then the previous episode" },
{ icon: SkipForward, label: "Next episode (only when there is one)" },
{ icon: Volume2, label: "Mute" },
];
const MORE: { icon: typeof Pause; label: string }[] = [
{ icon: Settings, label: "Player settings" },
{ icon: Info, label: "Media info" },
{ icon: Activity, label: "Stats for nerds" },
{ icon: Keyboard, label: "Keyboard shortcuts" },
{ icon: Download, label: "Download original file" },
];
const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function PlayerRow() {
return (
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-white/25 to-black text-white">
{/* Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch. */}
<div className="absolute inset-x-0 top-0 bottom-28 flex justify-between text-2xs text-white/70">
<span className="flex w-1/3 items-start justify-center border-r border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
<Rewind className="h-4 w-4" /> double-tap −10 s
</span>
</span>
<span className="flex w-1/3 items-start justify-center border-l border-dashed border-white/25 pt-6">
<span className="flex items-center gap-1">
double-tap +10 s <FastForward className="h-4 w-4" />
</span>
</span>
</div>
<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>
<div className="flex items-center">
{ROW.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={ctl} title="Audio & subtitles">
<Subtitles className="h-5 w-5" />
</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={`${SURFACE.menu} absolute bottom-16 right-2 flex w-52 flex-col text-xs text-fg`}
>
{MORE.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>
</div>
</div>
);
}
function MediaCard({ state }: { state: "new" | "started" | "touch" }) {
const pill = "inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium";
return (
<div className={`${SURFACE.card} flex w-44 flex-col gap-1.5 p-1.5`}>
<div className="relative aspect-2/3 overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
{state !== "new" && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<span className="block h-1 w-1/3 bg-accent" />
</span>
)}
{state === "new" && (
<span className="absolute inset-0 grid place-items-center bg-black/30">
<span className="grid h-12 w-12 place-items-center rounded-full bg-black/60 text-white">
<Play className="h-6 w-6" />
</span>
</span>
)}
{state === "started" && (
<span className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/30">
<span className={clsx(pill, "bg-accent text-accent-fg")}>
<Play className="h-4 w-4" /> Continue
</span>
<span className={clsx(pill, "bg-black/60 text-white")}>
<RotateCcw className="h-3.5 w-3.5" /> Restart
</span>
</span>
)}
</div>
<div className="flex items-start gap-1">
<div className="min-w-0 flex-1 px-0.5">
<div className="truncate text-xs font-medium">Darkman III</div>
<div className="text-2xs text-muted">
{state === "new" ? "1996 · not started" : "1996 · 14 min in"}
</div>
</div>
{state === "touch" && (
<span
className={clsx(
"grid h-11 w-11 shrink-0 place-items-center rounded-lg text-muted",
LAYER,
)}
>
<MoreHorizontal className="h-4 w-4" />
</span>
)}
</div>
</div>
);
}
function Cards() {
return (
<div className="flex flex-wrap gap-3">
<Sample label="Not started · hover">
<MediaCard state="new" />
</Sample>
<Sample label="Started · hover">
<MediaCard state="started" />
</Sample>
<Sample label="Started · touch: a tap continues, the '…' has the restart">
<MediaCard state="touch" />
</Sample>
</div>
);
}
function C5() {
return (
<Reference
id="c5"
code="C5"
title="The Plex player's controls"
rule="The owner's model. No Stop (closing is stopping), no Restart and no ±10 s buttons. Back: more than 3 s in → restart; within the first 3 s → the previous item, at its resume point if it has one. Next shows only when there is a next item (an episode, a playlist), starts it at its resume point, and leaves one behind (not watched, unless already in the credits). On touch a double-tap on the left / right third skips ∓10 s; the keyboard keeps ← / →. The rare tools sit in a fixed '⋯'. Restarting lives on the card: not started → Play; started → Continue + Restart, the video card's pair (touch: a tap continues, the '…' has the restart)."
>
<div className="flex flex-wrap items-start gap-4">
<div className="w-full max-w-xl">
<PlayerRow />
</div>
<Cards />
</div>
</Reference>
);
}