feat(design): C5 recorded, every E28 decision taken

The owner's lean Plex control set with both refinements (Back by position,
a touch double-tap for the 10 s skip) goes into DESIGN.md's Players
chapter and the log. The lab is now the reference only.
This commit is contained in:
2026-09-24 04:12:19 +02:00
parent 3f5634a44f
commit 9c4a999e54
4 changed files with 85 additions and 174 deletions
+4 -19
View File
@@ -3,10 +3,7 @@ import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC, C5 } from "./batchC";
// The open decisions, in the order they are asked. The decided ones render below them as reference.
const OPEN = [{ id: "c5", code: "C5", title: "Player controls", render: C5 }] as const;
import { BatchC } from "./batchC";
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
@@ -93,15 +90,6 @@ export default function DesignLab() {
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
{OPEN.map((d) => (
<a
key={d.id}
href={`#${d.id}`}
className="rounded-md px-1.5 py-0.5 text-muted hover:bg-card hover:text-fg"
>
{d.code} {d.title}
</a>
))}
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
A ✓
</a>
@@ -116,15 +104,12 @@ export default function DesignLab() {
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
<header>
<h1 className="text-2xl font-bold">Open · the Plex player controls</h1>
<h1 className="text-2xl font-bold">The design reference</h1>
<p className="text-sm text-muted">
Each decision shows its variants with the app&apos;s real CSS. The framed one is the
recommendation. Resize the window to tablet size to see them there too.
Every rule of docs/DESIGN.md in its chosen rendering, with the app&apos;s real CSS.
Resize the window to tablet size to see them there too.
</p>
</header>
{OPEN.map((d) => (
<d.render key={d.id} />
))}
<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">
+30 -44
View File
@@ -22,13 +22,12 @@ import {
} from "lucide-react";
import { Btn } from "./batchA";
import { C1 } from "./batchCActions";
import { Decision, Reference, Sample, Variant } from "./labKit";
import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens";
// E28 S2 · Batch C — behaviour on touch and on a narrow window. C1–C4 are DECIDED (owner, 2026-09-24:
// all as proposed) and stay as references. C5 is open: the live player pass (E28 S2d) found the Plex
// control row running off-screen below 1024; the owner answered with a reworked control set, and
// two of its points are asked back.
// 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";
@@ -206,14 +205,14 @@ export function BatchC() {
<C3 />
<C4 />
</div>
<C5 />
</div>
);
}
// ── C5 · the Plex player's controls, reworked (the owner's model, 2026-09-24) ─────────────────────
// The owner's direction: Stop, Restart and ±10 s leave the row; Back restarts or steps to the previous
// episode; Next exists only when there is a next item; restarting moves onto the card. What is left
// fits a portrait tablet in one row, with a fixed "⋯" for the rare tools.
// ── 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" },
@@ -231,24 +230,22 @@ const MORE: { icon: typeof Pause; label: string }[] = [
const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
function PlayerRow({ touchZones }: { touchZones: boolean }) {
function PlayerRow() {
return (
<div className="relative aspect-4/3 w-full overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
{touchZones && (
// 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>
{/* 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 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>
<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>
</div>
)}
</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">
@@ -352,31 +349,20 @@ function Cards() {
);
}
export function C5() {
function C5() {
return (
<Decision
<Reference
id="c5"
code="C5"
title="The Plex player's controls, reworked"
question="Your model: no Stop (closing is stopping), no Restart and no ±10 s buttons, Back restarts or goes to the previous episode, Next only where there is a next item, and restarting moves onto the card (Play when not started; Resume + From the start when started). It fits a 767 portrait tablet in one row, with a fixed '⋯'. Two points need your call: how Back tells 'restart' from 'previous', and how a tablet skips 10 s without a keyboard."
now="15 controls plus the time in one row that neither wraps nor scrolls: at 767 and at 820 portrait five of them are off-screen and unreachable. The card shows one Play ring; Resume and 'Clear resume point' exist only on the detail page."
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 → Resume + From the start (touch: a tap resumes, the '…' has From the start)."
>
<Variant
letter="A"
name="Your model, with two refinements"
recommended
why="1) Back decides by the position, the music-player rule: more than 3 s in → restart; within the first 3 s → the previous episode (at its resume point if it has one, else its start). That is your 'first press restarts, the second goes back', without a press counter: a counter goes wrong when you arrive at an episode by Next and want the one before it at once (the first press would restart an episode already at 0:00). 2) On touch, a double-tap on the left or right third of the picture skips ∓10 s (the YouTube and Netflix gesture); a single tap still only shows the controls (C3). Without it a tablet can't go back 10 s at all: on a 42-minute film one pixel of the seek bar is ≈ 3.5 s, and a finger lands within 10–20 px, so ±40–70 s."
>
<PlayerRow touchZones />
<div className="flex flex-wrap items-start gap-4">
<div className="w-full max-w-xl">
<PlayerRow />
</div>
<Cards />
</Variant>
<Variant
letter="B"
name="Your model, literally"
why="Back counts presses: the first restarts, a second one goes to the previous episode. ±10 s only from the keyboard (← / →), so on a tablet the seek bar is the only way to move, at a precision of tens of seconds. The row and the cards are the same as A."
>
<PlayerRow touchZones={false} />
</Variant>
</Decision>
</div>
</Reference>
);
}
+5 -76
View File
@@ -1,84 +1,13 @@
import { useSyncExternalStore, type ReactNode } from "react";
import clsx from "clsx";
// Building blocks of the design lab. A lab page asks the owner ONE question per <Decision>: two or
// three <Variant>s rendered side by side with the app's real CSS, the recommended one marked and its
// one-line reason (the rule it rests on) printed under it. Everything is static markup on purpose —
// what is being decided is the look, and a variant that only exists here must not need app state.
export function Decision({
id,
code,
title,
question,
now,
children,
}: {
id: string;
code: string;
title: string;
question: string;
/** What the app does today, measured (the E28 S1 inventory). */
now: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className="glass rounded-2xl 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>
<h2 className="text-lg font-bold">{title}</h2>
</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="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">{children}</div>
</section>
);
}
export function Variant({
letter,
name,
recommended = false,
why,
children,
}: {
letter: string;
name: string;
recommended?: boolean;
why: string;
children: ReactNode;
}) {
return (
<article
className={clsx(
"glass-card rounded-xl p-4 flex flex-col gap-3 border",
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-card text-xs font-bold">
{letter}
</span>
<h3 className="text-sm font-semibold flex-1">{name}</h3>
{recommended && (
<span className="rounded-full bg-accent px-2 py-0.5 text-2xs font-bold text-accent-fg">
Recommended
</span>
)}
</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>
);
}
// Building blocks of the design lab. Every E28 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 (E28 S2 history) for its open questions. 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
* reference half — the living style guide the open decisions sit on top of. */
* reference half — the living style guide. */
export function Reference({
id,
code,