From d2e40dcffb8707341eca28b5ab3366fb27b9abd6 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 01:34:12 +0200
Subject: [PATCH 001/137] feat(design): design lab + DESIGN.md skeleton (E28
S2a)
- docs/DESIGN.md: the written source of truth for the UI. Decided chapters
(principles, device adaptation, action placement, text stays selectable,
interaction rules, copy, anti-patterns, decision log); controls and
surfaces are marked Pending until the owner picks from the lab.
- /design-lab (dev only): renders each open decision's variants with the
app's real CSS, themes and font scale. Batch A = six control decisions.
- devOnlyGuard: a production build fails if anything under src/design-lab
reaches the bundle (seen red with an unguarded import).
- Size contract: tablet portrait + landscape and up, replacing the
1024x768 floor with its never-built "too small" message.
---
docs/DESIGN.md | 177 ++++++
docs/development.md | 4 +
frontend/devOnlyGuard.ts | 29 +
frontend/eslint.config.js | 7 +
.../src/components/ui/Button.dom.test.tsx | 2 +-
frontend/src/components/ui/Button.tsx | 2 +-
frontend/src/components/ui/README.md | 8 +-
frontend/src/design-lab/DesignLab.tsx | 126 ++++
frontend/src/design-lab/batchA.tsx | 572 ++++++++++++++++++
frontend/src/design-lab/labKit.tsx | 150 +++++
frontend/src/index.css | 2 +-
frontend/src/main.tsx | 14 +-
frontend/tsconfig.node.json | 1 +
frontend/vite.config.ts | 3 +-
14 files changed, 1087 insertions(+), 10 deletions(-)
create mode 100644 docs/DESIGN.md
create mode 100644 frontend/devOnlyGuard.ts
create mode 100644 frontend/src/design-lab/DesignLab.tsx
create mode 100644 frontend/src/design-lab/batchA.tsx
create mode 100644 frontend/src/design-lab/labKit.tsx
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
new file mode 100644
index 00000000..fbb707f5
--- /dev/null
+++ b/docs/DESIGN.md
@@ -0,0 +1,177 @@
+# DESIGN.md — how Siftlode looks and behaves
+
+This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern,
+and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
+code is either a decision still open here (marked **Pending**) or a violation to fix.
+
+**Status:** being written in E28. Chapters marked _Decided_ are in force. Chapters marked **Pending** are
+open decisions, each rendered side by side in the design lab (below) and settled by the product owner.
+
+## How this document changes
+
+- A rule changes only through an explicit decision recorded in the [decision log](#decision-log): what was
+ decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the
+ list for the next design round.
+- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or
+ Material. The tag is in brackets after the rule.
+- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule
+ that lives only in prose drifts. That is the finding that started this document.
+
+## The design lab
+
+`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
+with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and
+backdrop. It is dev-only: `main.tsx` reaches it behind `import.meta.env.DEV`, and the build plugin
+`frontend/devOnlyGuard.ts` fails a production build that would contain it. Once a decision is taken, the
+losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living
+style guide.
+
+## Principles
+
+1. **Glass over a calm background is the look.** Surfaces are translucent layers (`.glass`,
+ `.glass-card`, `.glass-menu`, `.glass-media`) over a per-scheme backdrop. Depth comes from the
+ **header + fade**: a crisp sticky header with a short gradient just below it, so content dissolves as it
+ slides under. The reference is `DataTable`'s sticky header. A flat opaque box where a glass surface
+ belongs is a violation.
+2. **Built for one known, daily user, on a desktop and a tablet.** Density is welcome where a mouse is
+ the pointer; on touch every target grows. Nothing is designed for phones.
+3. **One primitive per pattern.** Where a `components/ui/` primitive exists and is used, the looks don't
+ drift. The drift lives in hand-rolled markup. A new generic control goes into `ui/`, and a screen
+ composes primitives rather than restyling them.
+4. **Measured, not eyeballed.** Contrast, target sizes and layout at the contract sizes are measured in a
+ real browser before a UI change is called done.
+5. **Honest states.** An error never renders as an empty state; loading, empty and error are three
+ different screens (`QueryState` / `StateMessage`).
+
+## Device adaptation — _Decided_
+
+- **Supported:** tablet portrait and landscape and every size above them (from 768px wide). Phones are not a
+ target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size.
+- **Adapt by capability, never by user agent:** `@media (pointer: coarse)` and `(hover: none)` decide touch
+ behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never
+ literal pixel values scattered in call sites. [HIG, WCAG 1.4.10]
+- **Touch never loses a function.** Anything revealed on hover is visible at rest on `hover: none` (the
+ `.reveal` class), or it is removed on touch because the same action is reachable another way
+ (`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
+- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
+ overlapping hit areas fire the wrong control. [HIG, WCAG 2.5.8] _Which controls this covers beyond
+ `Button` is decision A3._
+- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
+ when an input gets focus.
+- **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**.
+
+## Tokens
+
+- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
+ --warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark
+ and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3,
+ 1.4.11] _The light-theme accent fix is decision B; the input edge token is decision A4._
+- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
+ px font size or a px-frozen box is an eslint error.
+- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. _Buttons, inputs, triggers
+ and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._
+- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
+ overlay < popover < tooltip`). No ad-hoc z-index. [maintainability]
+
+## Controls — **Pending (batch A)**
+
+Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button ·
+**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger ·
+**A6** checkbox, switch, slider.
+
+Already in force:
+- An action button is `ui/Button` with a `SIZES` rung. A hand-rolled `` is a
+ violation. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2]
+- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
+ Never a native `` (lint-enforced).
+- A filter chip goes through `FilterChipGroup`.
+- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
+ [Nielsen #1]
+
+## Surfaces and page chrome — **Pending (batch B)**
+
+Open: the page-chrome model (a sticky toolbar vs controls in the content, collapsing, the page title), the
+glass role per surface, the public watch page, the light-theme accent, menu items and popover surfaces, the
+nav rail, and the loading form (spinner vs skeleton).
+
+Already in force:
+- The glass role is load-bearing: a dropdown or menu is `.glass-menu` (it floats over undimmed content, so a
+ card alpha would bleed through), a dialog is `.glass`, and a grid card is `.glass-card`.
+- Every overlay is a `Modal`/`Overlay` or an anchored popover from the one popover engine. Escape closes the
+ topmost layer only, and focus returns to the control that opened it. [WCAG 2.4.3, 2.1.2]
+
+## Action placement — _Decided_
+
+Which actions are visible and which live in a menu.
+
+- **One menu, several triggers.** An object's actions are ONE list. The visible "…" button, a right-click,
+ a long-press (touch) and Shift+F10 / the Menu key all open the same `ContextMenu` built from that list.
+ A second, hand-made menu for the same object is a violation.
+- **Nothing is reachable only by a gesture.** Every menu action also has a visible path: the "…" button,
+ the page toolbar, or the object's detail view. A right-click is a shortcut, never the only door.
+ [Nielsen #6, #7]
+- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
+ (watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
+ Destructive ones keep their confirmation. [Nielsen #8, #5]
+- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. _Which actions stay
+ visible per object is part of batch C._
+- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
+ a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
+ filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
+ Desktop only: on touch a long-press on the background clashes with scrolling and text selection, so the
+ toolbar stays the touch path.
+
+### Text stays selectable — _Decided_
+
+Wherever there is text, the user can select it and copy it, and no click action overrides that.
+- A title or a name is never the text content of a ``. The play/open target is the thumbnail, an
+ icon, or a link-styled element.
+- A click that ends a text selection does nothing: if the selection inside the element is non-empty, or
+ the pointer moved past the drag slop, no play or open fires.
+- On touch the long-press belongs to the context menu, so the object's menu offers **Copy title** and
+ **Copy link**.
+
+## Interaction rules — _Decided_
+
+- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
+ `lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. [WCAG 2.1.1, 2.4.3, 2.1.2]
+- **Focus:** every control has a visible focus ring (the base ring in `index.css`; don't add a second). A
+ sticky or fixed layer never covers the focused element. [WCAG 2.4.7, 2.4.11]
+- **Menus and tabs follow WAI-ARIA APG:** arrow keys move, Home/End jump, Escape closes and returns focus;
+ tabs carry `tablist`/`tab`/`tabpanel` roles.
+- **Feedback:** a finished action is confirmed briefly (a toast that leaves by itself); anything that must
+ be read or acted on is not only in a toast. Status changes are announced through a live region.
+ [Nielsen #1, WCAG 2.2.1, 4.1.3]
+- **Errors:** a failed write goes through `meta.failTitle` / `notifyActionError`, never a silent `catch`.
+ An error sits next to what failed and says how to recover; a field error is under its field and linked
+ with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1]
+- **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one.
+ Space is reserved for late content so the layout doesn't jump. _Spinner vs skeleton: batch B._
+- **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom),
+ and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7]
+- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
+ [WCAG 2.3.3]
+
+## Copy
+
+- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
+ a structured `{code, reason}` translated on the client.
+- A label says what happens ("Delete playlist"), not "OK". A destructive confirm names the object.
+
+## Anti-patterns
+
+- A flat `bg-card` / `bg-surface` box where a glass surface belongs.
+- A hand-rolled control that restyles what a primitive already does.
+- Information or actions that exist only on hover, or only behind a right-click.
+- A click handler on text that swallows the user's text selection.
+- A colour literal, a px font size, a px-frozen box.
+- An empty state shown for an error.
+
+## Decision log
+
+| Date | Decision | Chosen | Over | Why |
+|---|---|---|---|---|
+| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
+| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
+| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
+| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
diff --git a/docs/development.md b/docs/development.md
index 0e7ae2f2..4b4227a2 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -32,6 +32,10 @@ For a fast frontend edit loop, run the Vite dev server with HMR against that API
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
```
+The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
+decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
+(see [`DESIGN.md`](DESIGN.md)).
+
## The gate
A change is done when all of this is green. CI runs the same checks server-side
diff --git a/frontend/devOnlyGuard.ts b/frontend/devOnlyGuard.ts
new file mode 100644
index 00000000..901e0339
--- /dev/null
+++ b/frontend/devOnlyGuard.ts
@@ -0,0 +1,29 @@
+import type { Plugin } from "vite";
+
+// The design lab (`src/design-lab/`) is a dev-only page: main.tsx reaches it behind
+// `import.meta.env.DEV`, which Vite folds to `false` in a production build, so the dynamic import
+// is dead code and the chunk is never emitted. That rule used to be a sentence; this makes it a
+// build failure. If a future edit imports the lab unguarded (a static import, a shared helper, a
+// re-export), the production build refuses instead of shipping the lab to every visitor.
+const DEV_ONLY_DIR = "/src/design-lab/";
+
+export function devOnlyGuard(): Plugin {
+ return {
+ name: "siftlode-dev-only-guard",
+ apply: "build",
+ generateBundle(_options, bundle) {
+ for (const [fileName, output] of Object.entries(bundle)) {
+ if (output.type !== "chunk") continue;
+ const leaked = Object.keys(output.modules).find((id) =>
+ id.replaceAll("\\", "/").includes(DEV_ONLY_DIR),
+ );
+ if (leaked) {
+ this.error(
+ `dev-only module ${leaked} reached the production bundle (${fileName}). ` +
+ "Import src/design-lab only behind `import.meta.env.DEV`.",
+ );
+ }
+ }
+ },
+ };
+}
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index c7011bba..6b3b63b5 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -357,6 +357,13 @@ export default tseslint.config(
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
rules: { "local/focus-safe-disabled": "off" },
},
+ {
+ // The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
+ // bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
+ // owner's working text, not the product's, so the translate-every-label rule does not apply.
+ files: ["src/design-lab/**/*.{ts,tsx}"],
+ rules: { "local/literal-a11y-text": "off" },
+ },
{
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
// …and render throwaway markup whose labels no user ever hears.
diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx
index b2e06dd1..bea62648 100644
--- a/frontend/src/components/ui/Button.dom.test.tsx
+++ b/frontend/src/components/ui/Button.dom.test.tsx
@@ -127,7 +127,7 @@ describe("Button primitive (S5)", () => {
});
it("carries the coarse-pointer minimum target, except on a link", () => {
- // The 1024x768 comfort contract: the minimum lives in the primitive, so no call site can opt
+ // The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
// out of it by accident. A link is text in a sentence and is excluded by design.
const { unmount } = render(Tap );
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
index 6dd5b9d4..a31ecc5a 100644
--- a/frontend/src/components/ui/Button.tsx
+++ b/frontend/src/components/ui/Button.tsx
@@ -155,7 +155,7 @@ const ButtonImpl = forwardRef(function Button
// here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
- // The 1024x768 comfort contract lives HERE, not in the call sites: on a coarse pointer every
+ // The tablet comfort contract (docs/DESIGN.md) lives HERE, not in the call sites: on a coarse pointer every
// button-shaped control gets at least a 2.75rem box (see .touch-target in index.css). `link` is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md
index 6569105f..e5aff777 100644
--- a/frontend/src/components/ui/README.md
+++ b/frontend/src/components/ui/README.md
@@ -46,10 +46,10 @@ domain-free.
## The size contract (E10 S1c)
-The app optimises for **1024x768 (tablet landscape) as the floor**; below it we owe an honest
-message, above it correct rendering all the way up. That contract lives in these components, not in
-the ~160 call sites, because a rule repeated in 160 places is a rule that is already broken
-somewhere:
+The app supports **tablet portrait and landscape and every size above them** (from 768px wide; phones
+are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Device adaptation). Measure a UI
+change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
+~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` are the rungs, derived from what
the app actually used. Pick a rung; do not hand-roll padding on a primitive.
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
new file mode 100644
index 00000000..f0ded6ef
--- /dev/null
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -0,0 +1,126 @@
+import { useLayoutEffect, useState } from "react";
+import clsx from "clsx";
+import { SCHEMES, type Scheme } from "../lib/theme";
+import { A1, A2, A3, A4, A5, A6 } from "./batchA";
+
+const BATCH_A = [
+ { id: "a1", code: "A1", title: "Button shape", render: A1 },
+ { id: "a2", code: "A2", title: "Icon buttons", render: A2 },
+ { id: "a3", code: "A3", title: "Touch size", render: A3 },
+ { id: "a4", code: "A4", title: "Input", render: A4 },
+ { id: "a5", code: "A5", title: "Dropdown trigger", render: A5 },
+ { id: "a6", code: "A6", title: "Checkbox / switch / slider", render: A6 },
+] as const;
+
+// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
+// OUTSIDE , so it owns the theme attributes on itself, through the same data-theme /
+// data-scheme / --font-scale / data-backdrop switches the app's PrefsProvider drives. Nothing here
+// is saved: the lab's bar changes this tab only.
+
+type Look = { mode: "dark" | "light"; scheme: Scheme; fontScale: number; backdrop: boolean };
+
+// The owner's own daily look (E28 S1 inventory) is the starting point — decisions are taken on it.
+const START: Look = { mode: "dark", scheme: "midnight", fontScale: 1.02, backdrop: true };
+
+function applyLook(l: Look) {
+ const el = document.documentElement;
+ el.dataset.theme = l.mode;
+ el.dataset.scheme = l.scheme;
+ el.dataset.backdrop = l.backdrop ? "on" : "off";
+ el.style.setProperty("--font-scale", String(l.fontScale));
+}
+
+const chip = (on: boolean) =>
+ clsx(
+ "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition",
+ on ? "border-accent bg-accent text-accent-fg" : "border-border text-muted hover:text-fg",
+ );
+
+export default function DesignLab() {
+ const [look, setLook] = useState(START);
+ useLayoutEffect(() => {
+ applyLook(START);
+ document.title = "Design lab · Siftlode";
+ }, []);
+ const set = (patch: Partial) => {
+ const next = { ...look, ...patch };
+ applyLook(next);
+ setLook(next);
+ };
+
+ return (
+
+
+
Design lab
+
+ {(["dark", "light"] as const).map((m) => (
+ set({ mode: m })}
+ >
+ {m}
+
+ ))}
+
+
+ {SCHEMES.map((s) => (
+ set({ scheme: s.id })}
+ >
+
+ {s.name}
+
+ ))}
+
+
set({ backdrop: !look.backdrop })}
+ >
+ backdrop
+
+
+ text {look.fontScale.toFixed(2)}
+ set({ fontScale: Number(e.target.value) })}
+ className="w-24 accent-accent"
+ />
+
+
+ {BATCH_A.map((d) => (
+
+ {d.code} {d.title}
+
+ ))}
+
+
+
+
+
+ {BATCH_A.map((d) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/design-lab/batchA.tsx b/frontend/src/design-lab/batchA.tsx
new file mode 100644
index 00000000..7c108a13
--- /dev/null
+++ b/frontend/src/design-lab/batchA.tsx
@@ -0,0 +1,572 @@
+import clsx from "clsx";
+import type { CSSProperties, ReactNode } from "react";
+import {
+ Check,
+ ChevronDown,
+ Download,
+ ListPlus,
+ MoreHorizontal,
+ Pencil,
+ Play,
+ RefreshCw,
+ Search,
+ Trash2,
+} from "lucide-react";
+import { Contrast, Decision, Sample, Variant } from "./labKit";
+
+// E28 S2 · Batch A — controls. Each decision renders its variants with the app's real CSS classes
+// (glass, btn-primary, tokens); nothing here is wired to data. The markup is the proposal itself.
+
+// ── shared proposal pieces ─────────────────────────────────────────────────────────────────────
+type Tone = "primary" | "ghost" | "outline" | "glass" | "danger";
+type Size = "xs" | "sm" | "md";
+type Radius = "rounded-lg" | "rounded-xl" | "rounded-full";
+
+const TONE: Record = {
+ primary: "font-medium btn-primary",
+ ghost: "font-medium text-muted hover:text-fg hover:bg-card",
+ outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent",
+ glass: "font-medium glass-card glass-hover hover:text-accent",
+ danger: "font-medium btn-danger",
+};
+const SIZE: Record = {
+ xs: "px-2 py-1 text-xs gap-1",
+ sm: "px-3 py-1.5 text-sm gap-1.5",
+ md: "px-3 py-2 text-sm gap-1.5",
+};
+
+function Btn({
+ tone,
+ size,
+ radius,
+ icon,
+ children,
+}: {
+ tone: Tone;
+ size: Size;
+ radius: Radius;
+ icon?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
+
+const ico = "w-4 h-4";
+
+// ── A1 · button shape and ladder ──────────────────────────────────────────────────────────────────
+function ButtonSet({ radius }: { radius: Radius }) {
+ return (
+ <>
+
+
+ }>
+ Sync now
+
+
+ Full history
+
+
+
+
+
+
+ Cancel
+
+ }>
+ Delete
+
+
+ Save
+
+
+
+
+
+ Download failed · HTTP 403
+
+ Retry
+
+
+
+ >
+ );
+}
+
+export function A1() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── A2 · icon buttons ─────────────────────────────────────────────────────────────────────────────
+function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function OnMedia({ radius }: { radius: string }) {
+ const b = clsx(
+ "grid h-8 w-8 place-items-center bg-black/55 text-white hover:bg-black/75",
+ radius,
+ );
+ return (
+
+ );
+}
+
+function IconSet({ row, bar, media }: { row: string; bar: string; media: string }) {
+ const r = row.includes("full") ? "rounded-full" : "rounded-lg";
+ return (
+ <>
+
+
+
Techno Tim
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function A2() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── A3 · the touch rule beyond Button ────────────────────────────────────────────────────────────
+function TouchRow({ h, gap }: { h: string; gap: string }) {
+ const pill = clsx("inline-flex items-center px-3 text-sm", h);
+ return (
+
+
+
+
+ Mine
+ Library
+
+
+ Sort: Newest
+
+
+
+
+
+
+ Queue
+ Library
+
+
+
+
+ Dark mode
+
+
+
+
+
+
+
+ );
+}
+
+export function A3() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── A4 · the input ───────────────────────────────────────────────────────────────────────────────
+// The proposed stronger border: the text colour mixed into the page background, opaque, so its
+// contrast is the same on glass and on flat surfaces. Measured over all 12 scheme × mode pairs:
+// 45% fails (light/forest 2.68), 50% scrapes by (3.06), 55% clears 3:1 with margin (worst 3.52).
+const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
+
+function Field({
+ cls,
+ style,
+ searchCls,
+ searchStyle,
+}: {
+ cls: string;
+ style?: CSSProperties;
+ searchCls: string;
+ searchStyle?: CSSProperties;
+}) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ Playlist name
+
+ A playlist with this name already exists.
+
+
+ >
+ );
+}
+
+export function A4() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── A5 · dropdown trigger ────────────────────────────────────────────────────────────────────────
+function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) {
+ return (
+
+ {label}: {value}
+
+
+ );
+}
+
+function DropdownSet({ bar, form }: { bar: string; form: string }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ Download format
+
+ MP4 · 1080p
+
+
+
+
+ >
+ );
+}
+
+export function A5() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── A6 · checkbox, switch, slider ────────────────────────────────────────────────────────────────
+function NativeSet() {
+ return (
+ <>
+
+
+ Only unwatched
+
+
+ Hide Shorts
+
+
+
+
+
+ Text size
+
+
+ >
+ );
+}
+
+function CustomSet() {
+ return (
+ <>
+
+
+
+
+ Only unwatched
+
+
+ Hide Shorts
+
+
+
+
+
+ Text size
+
+
+
+
+
+ >
+ );
+}
+
+export function A6() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx
new file mode 100644
index 00000000..473f96ec
--- /dev/null
+++ b/frontend/src/design-lab/labKit.tsx
@@ -0,0 +1,150 @@
+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 : two or
+// three 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 (
+
+
+
+ {code}
+
{title}
+
+ {question}
+
+ Today:
+ {now}
+
+
+ {children}
+
+ );
+}
+
+export function Variant({
+ letter,
+ name,
+ recommended = false,
+ why,
+ children,
+}: {
+ letter: string;
+ name: string;
+ recommended?: boolean;
+ why: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {letter}
+
+
{name}
+ {recommended && (
+
+ Recommended
+
+ )}
+
+ {children}
+ {why}
+
+ );
+}
+
+/** A small caption above a sample inside a variant. */
+export function Sample({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+// ── Measured contrast ───────────────────────────────────────────────────────────────────────────
+// A variant that changes a colour prints its contrast against the page background, computed from the
+// live CSS — so switching the theme or the scheme in the lab's bar re-measures it. The theme lives on
+// 's attributes, so that is what the store subscribes to.
+
+function subscribeTheme(onChange: () => void) {
+ const mo = new MutationObserver(onChange);
+ mo.observe(document.documentElement, { attributes: true });
+ return () => mo.disconnect();
+}
+
+let probe: HTMLSpanElement | null = null;
+/** Resolve any CSS colour expression (vars, color-mix) to [r, g, b, a] in 0..1 via a hidden probe. */
+function resolve(expr: string): [number, number, number, number] {
+ if (!probe) {
+ probe = document.createElement("span");
+ probe.style.display = "none";
+ document.body.append(probe);
+ }
+ probe.style.color = "";
+ probe.style.color = expr;
+ const c = getComputedStyle(probe).color;
+ const [r = 0, g = 0, b = 0, a = 1] = (c.match(/[\d.]+/g) ?? []).map(Number);
+ // `rgb()` is 0..255; a color-mix() result computes to `color(srgb …)`, already 0..1.
+ return c.startsWith("color(") ? [r, g, b, a] : [r / 255, g / 255, b / 255, a];
+}
+
+const lin = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
+const lum = (r: number, g: number, b: number) =>
+ 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
+
+function ratio(fg: string, bg: string): number {
+ const [br, bg_, bb] = resolve(bg);
+ const [fr, fg_, fb, fa] = resolve(fg);
+ // Composite a translucent foreground over the background, as the eye sees it.
+ const over = (f: number, b: number) => f * fa + b * (1 - fa);
+ const l1 = lum(over(fr, br), over(fg_, bg_), over(fb, bb));
+ const l2 = lum(br, bg_, bb);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+}
+
+/** "3.42:1 ✓" against `min` — re-measured whenever the lab switches theme or scheme. */
+export function Contrast({
+ fg,
+ bg = "var(--bg)",
+ min,
+ label,
+}: {
+ fg: string;
+ bg?: string;
+ min: number;
+ label: string;
+}) {
+ const value = useSyncExternalStore(subscribeTheme, () => ratio(fg, bg).toFixed(2));
+ const ok = Number(value) >= min;
+ return (
+
+ {label}: {value}:1 {ok ? "✓" : `✗ (needs ${min}:1)`}
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index fa6267a1..947f6e0b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -583,7 +583,7 @@ body {
opacity: 0.9;
}
- /* ===== E10 S1c · the minimum-target half of the 1024x768 comfort contract =====
+ /* ===== E10 S1c · the minimum-target half of the tablet comfort contract (docs/DESIGN.md) =====
A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and
not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on
this app's real second device — a tablet — is exactly where it is needed.
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 2b50c27d..08db0e45 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -38,6 +38,10 @@ const App = lazyWithPreload(() => import("./App"));
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
+// The design lab (E28): the living style guide, rendered with the app's real CSS so a design decision
+// is taken on what will ship. DEV only — Vite folds the condition to `false` in a production build,
+// the import becomes dead code and the chunk is never emitted (devOnlyGuard fails the build if it is).
+const DesignLab = import.meta.env.DEV ? lazy(() => import("./design-lab/DesignLab")) : null;
const queryClient = createQueryClient();
@@ -49,13 +53,19 @@ de.theme ||= "dark";
de.scheme ||= "midnight";
const path = window.location.pathname;
-const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/");
+const isLab = DesignLab !== null && path === "/design-lab";
+const isPublicPage =
+ path === "/privacy" || path === "/terms" || path.startsWith("/watch/") || isLab;
// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens:
// the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A
// failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary.
if (!isPublicPage) App.preload().catch(() => {});
const root =
- path === "/privacy" ? (
+ isLab && DesignLab ? (
+
+
+
+ ) : path === "/privacy" ? (
) : path === "/terms" ? (
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
index 1c9ac7a5..3b6092e4 100644
--- a/frontend/tsconfig.node.json
+++ b/frontend/tsconfig.node.json
@@ -37,6 +37,7 @@
"vite.config.ts",
"vitest.config.ts",
"reactPlugin.ts",
+ "devOnlyGuard.ts",
"tailwind.config.js",
"postcss.config.js"
]
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b49c66a3..d7f1f568 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,5 +1,6 @@
import { defineConfig } from "vite";
import { reactWithCompiler } from "./reactPlugin";
+import { devOnlyGuard } from "./devOnlyGuard";
// During `vite dev` (host with Node), proxy API calls to the backend container.
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
@@ -20,7 +21,7 @@ const proxy = {
};
export default defineConfig({
- plugins: [reactWithCompiler()],
+ plugins: [reactWithCompiler(), devOnlyGuard()],
server: { port: 5173, proxy },
build: { outDir: "dist" },
});
From 50923bb6847ef468df68e4fa98b4336e03c6332c Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 02:21:20 +0200
Subject: [PATCH 002/137] fix(build): keep JSX line-end spaces in dev and
vitest
plugin-react recognises the React Compiler only by its package name, and
reactPlugin.ts passes the imported plugin, so plugin-react left Babel's
retainLines on in dev. With the compiler's re-printed JSX that trimmed the
space at the end of a text line (the privacy page read
"for example/api/feed" on :5173). Production builds never had it on.
Turn retainLines off through generatorOpts, which wins over the plugin's
top-level option, and pin it with a DOM test seen red without the fix.
---
frontend/reactPlugin.ts | 8 ++++++
frontend/src/jsxWhitespace.dom.test.tsx | 35 +++++++++++++++++++++++++
2 files changed, 43 insertions(+)
create mode 100644 frontend/src/jsxWhitespace.dom.test.tsx
diff --git a/frontend/reactPlugin.ts b/frontend/reactPlugin.ts
index 044a1da6..ce3ddc66 100644
--- a/frontend/reactPlugin.ts
+++ b/frontend/reactPlugin.ts
@@ -18,9 +18,17 @@ import reactCompiler from "babel-plugin-react-compiler";
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
// while a bad import is a resolve error on the tsc lane.
+//
+// The price of the import: plugin-react finds the compiler only by that string, so it doesn't know the
+// compiler is there. Its one compiler-specific switch is Babel's `retainLines`, which it turns on for
+// dev (and so for vitest) unless the compiler is present. With the compiler's re-printed JSX that eats
+// the space at the end of a text line ("for example " rendered "for example/api/feed"). The
+// production build never had it on. `generatorOpts` wins over the top-level option, so it is switched
+// off here, for every mode. Pinned by src/jsxWhitespace.dom.test.tsx.
export const reactWithCompiler = () =>
react({
babel: {
plugins: [[reactCompiler, { target: "19" }]],
+ generatorOpts: { retainLines: false },
},
});
diff --git a/frontend/src/jsxWhitespace.dom.test.tsx b/frontend/src/jsxWhitespace.dom.test.tsx
new file mode 100644
index 00000000..5fd0021e
--- /dev/null
+++ b/frontend/src/jsxWhitespace.dom.test.tsx
@@ -0,0 +1,35 @@
+// @vitest-environment jsdom
+//
+// The React transform must not eat the space between a word and the element or expression that
+// follows it on the same line. It did, in dev and in this test lane (never in the production build):
+// @vitejs/plugin-react only recognises the React Compiler by its package NAME, and reactPlugin.ts
+// passes the imported plugin, so the React plugin kept Babel's `retainLines` on. The compiler then
+// printed "for example " and the next element on different lines, and the JSX transform trimmed the
+// line-end space ("for example/api/feed" on the privacy page). reactPlugin.ts now turns
+// `retainLines` off itself. The component goes through the same transform the app does.
+import { describe, it, expect, afterEach } from "vitest";
+import { render, cleanup } from "@testing-library/react";
+
+afterEach(cleanup);
+
+function Sentence({ value }: { value: string }) {
+ // The line breaks ARE the test: collapsed onto one line, the defect doesn't fire.
+ // prettier-ignore
+ return (
+
+ which part of the app was called (for example /api/feed, never the search
+
+ status: {value} end
+
+
+ );
+}
+
+describe("JSX text whitespace through the React transform", () => {
+ it("keeps the space before an element and before an expression", () => {
+ const { container } = render( );
+ expect(container.textContent).toBe(
+ "which part of the app was called (for example /api/feed, never the searchstatus: ok end",
+ );
+ });
+});
From 477271907e9b72b44fecb0c7930b9d95cd6c2155 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 02:21:20 +0200
Subject: [PATCH 003/137] feat(design): batch A recorded, batch B in the design
lab (E28 S2b)
DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows;
density stays open as B1. The lab keeps only the chosen A renderings as
reference and adds batch B: density, the scheme set (measured accent hue
gaps and light contrast), state layers, the page-chrome model, surface per
role, the public watch page, the nav rail, the loading form and where the
header + fade edge goes.
---
docs/DESIGN.md | 71 ++-
frontend/src/design-lab/DesignLab.tsx | 38 +-
frontend/src/design-lab/batchA.tsx | 300 ++--------
frontend/src/design-lab/batchB.tsx | 565 ++++++++++++++++++
frontend/src/design-lab/batchBChrome.tsx | 719 +++++++++++++++++++++++
frontend/src/design-lab/labColor.ts | 33 ++
frontend/src/design-lab/labKit.tsx | 29 +
7 files changed, 1481 insertions(+), 274 deletions(-)
create mode 100644 frontend/src/design-lab/batchB.tsx
create mode 100644 frontend/src/design-lab/batchBChrome.tsx
create mode 100644 frontend/src/design-lab/labColor.ts
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index fbb707f5..b0b27dff 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -54,8 +54,8 @@ style guide.
`.reveal` class), or it is removed on touch because the same action is reachable another way
(`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
- overlapping hit areas fire the wrong control. [HIG, WCAG 2.5.8] _Which controls this covers beyond
- `Button` is decision A3._
+ overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button`
+ (A3, under Controls). [HIG, WCAG 2.5.8]
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
when an input gets focus.
- **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**.
@@ -63,23 +63,40 @@ style guide.
## Tokens
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
- --warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark
+--warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark
and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3,
- 1.4.11] _The light-theme accent fix is decision B; the input edge token is decision A4._
+ 1.4.11] _The scheme set and the light-theme accent are decision B2._ The input edge token is set under
+ Controls (A4).
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
px font size or a px-frozen box is an eslint error.
-- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. _Buttons, inputs, triggers
- and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._
+- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
+ form fields `rounded-lg`; toolbar triggers and search a pill (Controls). _Surfaces per role: decision B5._
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
- overlay < popover < tooltip`). No ad-hoc z-index. [maintainability]
+overlay < popover < tooltip`). No ad-hoc z-index. [maintainability]
-## Controls — **Pending (batch A)**
+## Controls — _Decided_ (batch A; density **Pending**, B1)
-Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button ·
-**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger ·
-**A6** checkbox, switch, slider.
+The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3.
+
+- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12,
+ dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel
+ headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. _The rung
+ heights (standard 38/32/26 or compact 32/28/24) are decision B1._ [HIG, Material]
+- **Icon buttons (A2):** two sizes, r8: the smaller in rows and dense toolbars, the larger on its own. On a
+ picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only.
+- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
+ `Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
+ sizes don't change. [HIG, WCAG 2.5.8]
+- **Input (A4):** one bordered input primitive. The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`,
+ ≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height; a toolbar search
+ is a pill. Text is 1rem on a coarse pointer. [WCAG 1.4.11]
+- **Dropdown trigger (A5):** a pill in a toolbar (where the pill means "a filter or a choice"), r8 in a form
+ (it lines up with the inputs).
+- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color`; the whole
+ labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser.
+
+Also in force:
-Already in force:
- An action button is `ui/Button` with a `SIZES` rung. A hand-rolled `` is a
violation. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2]
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
@@ -90,11 +107,14 @@ Already in force:
## Surfaces and page chrome — **Pending (batch B)**
-Open: the page-chrome model (a sticky toolbar vs controls in the content, collapsing, the page title), the
-glass role per surface, the public watch page, the light-theme accent, menu items and popover surfaces, the
-nav rail, and the loading form (spinner vs skeleton).
+Open in the lab: **B1** button density · **B2** the scheme set (and the light-theme accent) · **B3** state
+layers (hover, pressed) · **B4** the page-chrome model (a sticky band vs controls in the content, folding on
+a short screen, the page `h1`) · **B5** the surface and radius per role (card, dialog, menu, tooltip) · **B6**
+the public watch page · **B7** the nav rail · **B8** the loading form (spinner vs skeleton) · **B9** where
+the header + fade edge goes.
Already in force:
+
- The glass role is load-bearing: a dropdown or menu is `.glass-menu` (it floats over undimmed content, so a
card alpha would bleed through), a dialog is `.glass`, and a grid card is `.glass-card`.
- Every overlay is a `Modal`/`Overlay` or an anchored popover from the one popover engine. Escape closes the
@@ -124,6 +144,7 @@ Which actions are visible and which live in a menu.
### Text stays selectable — _Decided_
Wherever there is text, the user can select it and copy it, and no click action overrides that.
+
- A title or a name is never the text content of a ``. The play/open target is the thumbnail, an
icon, or a link-styled element.
- A click that ends a text selection does nothing: if the selection inside the element is non-empty, or
@@ -146,7 +167,7 @@ Wherever there is text, the user can select it and copy it, and no click action
An error sits next to what failed and says how to recover; a field error is under its field and linked
with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1]
- **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one.
- Space is reserved for late content so the layout doesn't jump. _Spinner vs skeleton: batch B._
+ Space is reserved for late content so the layout doesn't jump. _Spinner vs skeleton: decision B8._
- **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom),
and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7]
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
@@ -169,9 +190,15 @@ Wherever there is text, the user can select it and copy it, and no click action
## Decision log
-| Date | Decision | Chosen | Over | Why |
-|---|---|---|---|---|
-| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
-| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
-| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
-| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
+| Date | Decision | Chosen | Over | Why |
+| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------- |
+| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
+| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
+| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
+| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
+| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
+| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
+| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
+| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
+| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
+| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index f0ded6ef..4101fdd8 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -1,15 +1,21 @@
import { useLayoutEffect, useState } from "react";
import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
-import { A1, A2, A3, A4, A5, A6 } from "./batchA";
+import { BatchA } from "./batchA";
+import { B1, B2, B3 } from "./batchB";
+import { B4, B5, B6, B7, B8, B9 } from "./batchBChrome";
-const BATCH_A = [
- { id: "a1", code: "A1", title: "Button shape", render: A1 },
- { id: "a2", code: "A2", title: "Icon buttons", render: A2 },
- { id: "a3", code: "A3", title: "Touch size", render: A3 },
- { id: "a4", code: "A4", title: "Input", render: A4 },
- { id: "a5", code: "A5", title: "Dropdown trigger", render: A5 },
- { id: "a6", code: "A6", title: "Checkbox / switch / slider", render: A6 },
+// The open decisions, in the order they are asked. The decided ones render below them as reference.
+const OPEN = [
+ { id: "b1", code: "B1", title: "Density", render: B1 },
+ { id: "b2", code: "B2", title: "Schemes", render: B2 },
+ { id: "b3", code: "B3", title: "Hover", render: B3 },
+ { id: "b4", code: "B4", title: "Page chrome", render: B4 },
+ { id: "b5", code: "B5", title: "Surfaces", render: B5 },
+ { id: "b6", code: "B6", title: "/watch", render: B6 },
+ { id: "b7", code: "B7", title: "Nav rail", render: B7 },
+ { id: "b8", code: "B8", title: "Loading", render: B8 },
+ { id: "b9", code: "B9", title: "Fade", render: B9 },
] as const;
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
@@ -97,7 +103,7 @@ export default function DesignLab() {
/>
- {BATCH_A.map((d) => (
+ {OPEN.map((d) => (
))}
+
+ A ✓
+
- {BATCH_A.map((d) => (
+ {OPEN.map((d) => (
))}
+
+
);
diff --git a/frontend/src/design-lab/batchA.tsx b/frontend/src/design-lab/batchA.tsx
index 7c108a13..c83ab7dc 100644
--- a/frontend/src/design-lab/batchA.tsx
+++ b/frontend/src/design-lab/batchA.tsx
@@ -1,7 +1,6 @@
import clsx from "clsx";
import type { CSSProperties, ReactNode } from "react";
import {
- Check,
ChevronDown,
Download,
ListPlus,
@@ -12,10 +11,11 @@ import {
Search,
Trash2,
} from "lucide-react";
-import { Contrast, Decision, Sample, Variant } from "./labKit";
+import { Contrast, Reference, Sample } from "./labKit";
-// E28 S2 · Batch A — controls. Each decision renders its variants with the app's real CSS classes
-// (glass, btn-primary, tokens); nothing here is wired to data. The markup is the proposal itself.
+// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended). Only the chosen
+// rendering of each stays, as the reference the S3 primitives are built to; the losing variants were
+// deleted with the pick. The markup uses the app's real CSS classes and is wired to no data.
// ── shared proposal pieces ─────────────────────────────────────────────────────────────────────
type Tone = "primary" | "ghost" | "outline" | "glass" | "danger";
@@ -105,41 +105,6 @@ function ButtonSet({ radius }: { radius: Radius }) {
);
}
-export function A1() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
// ── A2 · icon buttons ─────────────────────────────────────────────────────────────────────────────
function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) {
return (
@@ -212,41 +177,6 @@ function IconSet({ row, bar, media }: { row: string; bar: string; media: string
);
}
-export function A2() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
// ── A3 · the touch rule beyond Button ────────────────────────────────────────────────────────────
function TouchRow({ h, gap }: { h: string; gap: string }) {
const pill = clsx("inline-flex items-center px-3 text-sm", h);
@@ -287,41 +217,6 @@ function TouchRow({ h, gap }: { h: string; gap: string }) {
);
}
-export function A3() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
// ── A4 · the input ───────────────────────────────────────────────────────────────────────────────
// The proposed stronger border: the text colour mixed into the page background, opaque, so its
// contrast is the same on glass and on flat surfaces. Measured over all 12 scheme × mode pairs:
@@ -365,55 +260,6 @@ function Field({
);
}
-export function A4() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
// ── A5 · dropdown trigger ────────────────────────────────────────────────────────────────────────
function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) {
return (
@@ -454,41 +300,6 @@ function DropdownSet({ bar, form }: { bar: string; form: string }) {
);
}
-export function A5() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
// ── A6 · checkbox, switch, slider ────────────────────────────────────────────────────────────────
function NativeSet() {
return (
@@ -517,56 +328,63 @@ function NativeSet() {
);
}
-function CustomSet() {
+export function BatchA() {
return (
- <>
-
-
-
-
- Only unwatched
-
-
- Hide Shorts
-
-
-
-
-
- Text size
-
-
-
-
-
- >
- );
-}
-
-export function A6() {
- return (
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
);
}
diff --git a/frontend/src/design-lab/batchB.tsx b/frontend/src/design-lab/batchB.tsx
new file mode 100644
index 00000000..ab6b8767
--- /dev/null
+++ b/frontend/src/design-lab/batchB.tsx
@@ -0,0 +1,565 @@
+import clsx from "clsx";
+import type { CSSProperties, ReactNode } from "react";
+import {
+ ChevronDown,
+ Download,
+ MoreHorizontal,
+ Pencil,
+ RefreshCw,
+ Search,
+ Trash2,
+} from "lucide-react";
+import { hexContrast, hueGap } from "./labColor";
+import { Contrast, Decision, Sample, Variant } from "./labKit";
+
+// E28 S2 · Batch B, part 1 — the look of the controls themselves: density (A1's open sub-point),
+// the scheme set, and the hover / pressed state layers. Static markup on the app's real CSS.
+
+const ico = "w-4 h-4";
+const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
+
+// ── B1 · density ─────────────────────────────────────────────────────────────────────────────────
+// One ladder per variant. "Compact" sets explicit heights so a toolbar row lines up: the sm button,
+// the search pill, the dropdown trigger and the small icon button are all the same 28px.
+type Ladder = {
+ md: string;
+ sm: string;
+ xs: string;
+ iconSm: string;
+ icon: string;
+ field: string;
+ bar: string;
+};
+const STANDARD: Ladder = {
+ md: "px-3 py-2 text-sm gap-1.5",
+ sm: "px-3 py-1.5 text-sm gap-1.5",
+ xs: "px-2 py-1 text-xs gap-1",
+ iconSm: "w-8 h-8",
+ icon: "w-9 h-9",
+ field: "h-9.5",
+ bar: "h-9",
+};
+const COMPACT: Ladder = {
+ md: "h-8 px-3 text-sm gap-1.5",
+ sm: "h-7 px-2.5 text-sm gap-1.5",
+ xs: "h-6 px-2 text-xs gap-1",
+ iconSm: "w-7 h-7",
+ icon: "w-8 h-8",
+ field: "h-8",
+ bar: "h-7",
+};
+
+function LBtn({
+ cls,
+ tone,
+ icon,
+ children,
+}: {
+ cls: string;
+ tone: string;
+ icon?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
+
+const GHOST = "text-muted hover:text-fg hover:bg-card";
+const OUTLINE = "border border-border text-muted hover:text-accent hover:border-accent";
+const GLASS = "glass-card glass-hover hover:text-accent";
+
+function IconBtn({ box, children }: { box: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function DensitySet({ l }: { l: Ladder }) {
+ return (
+ <>
+
+
+
+
+
+
+
+ Sort: Newest
+
+
+ }>
+ Sync now
+
+
+
+
+
+
+
+
+
+ Playlist name
+
+
+
+
+ Cancel
+
+ }>
+ Delete
+
+
+ Save
+
+
+
+
+
+
+
Download failed · HTTP 403
+
+
+ Retry
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Download
+
+
+
+
+
+
+ >
+ );
+}
+
+export function B1() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── B2 · the scheme set ──────────────────────────────────────────────────────────────────────────
+// A proposed scheme is rendered by scoping the tokens on a wrapper: every utility resolves var(--…)
+// at the use site (the `@theme inline` block in index.css), so bg-accent inside the tile paints the
+// tile's accent, not the page's. The numbers under a tile come from the hex values, not the probe.
+
+type Pal = {
+ bg: string;
+ surface: string;
+ card: string;
+ border: string;
+ fg: string;
+ muted: string;
+ accent: string;
+ accentFg: string;
+};
+type Mode = "dark" | "light";
+type SchemeDef = { name: string; dark: Pal; light: Pal };
+
+const STATUS: Record> = {
+ dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
+ light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
+};
+
+const pal = (
+ bg: string,
+ surface: string,
+ card: string,
+ border: string,
+ fg: string,
+ muted: string,
+ accent: string,
+ accentFg: string,
+): Pal => ({ bg, surface, card, border, fg, muted, accent, accentFg });
+
+// Today's six, copied from index.css (the scheme blocks).
+const N = {
+ midnight: {
+ dark: ["#0b1020", "#121a2e", "#161f38", "#243049", "#e6e9f0", "#97a3c0"],
+ light: ["#f4f6fc", "#ffffff", "#ffffff", "#dde3f0", "#1a2238", "#5b6685"],
+ },
+ forest: {
+ dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"],
+ light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"],
+ },
+ slate: {
+ dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"],
+ light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"],
+ },
+ youtube: {
+ dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"],
+ light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"],
+ },
+ starship: {
+ dark: ["#050b16", "#0b1728", "#101f36", "#1f3557", "#dfeaff", "#8aa6cc"],
+ light: ["#eef4fb", "#ffffff", "#ffffff", "#d3e0f0", "#0b1b30", "#4a5f7d"],
+ },
+ matrix: {
+ dark: ["#030a06", "#08130d", "#0b1c12", "#163d27", "#cfe8d6", "#6fae86"],
+ light: ["#f0f7f1", "#ffffff", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56"],
+ },
+} as const;
+
+function scheme(
+ name: string,
+ id: keyof typeof N,
+ dark: [string, string],
+ light: [string, string],
+): SchemeDef {
+ const [db, ds, dc, dbo, df, dm] = N[id].dark;
+ const [lb, ls, lc, lbo, lf, lm] = N[id].light;
+ return {
+ name,
+ dark: pal(db, ds, dc, dbo, df, dm, dark[0], dark[1]),
+ light: pal(lb, ls, lc, lbo, lf, lm, light[0], light[1]),
+ };
+}
+
+const TODAY: SchemeDef[] = [
+ scheme("Midnight", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
+ scheme("Forest", "forest", ["#2dd4bf", "#04110d"], ["#0d9488", "#ffffff"]),
+ scheme("Slate", "slate", ["#e8913c", "#1a1206"], ["#d97706", "#ffffff"]),
+ scheme("YouTube", "youtube", ["#ff3b46", "#ffffff"], ["#ff0033", "#ffffff"]),
+ scheme("Starship", "starship", ["#38b6ff", "#04101f"], ["#0b6bd6", "#ffffff"]),
+ scheme("Matrix", "matrix", ["#46b87c", "#04170c"], ["#12a150", "#ffffff"]),
+];
+
+// The proposal: four schemes, four hue families far from each other and from red / amber / green.
+// Only the accents move; every background and text colour stays as it is.
+const PROPOSED: SchemeDef[] = [
+ scheme("Midnight · blue", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
+ scheme("Forest · cyan", "forest", ["#22d3ee", "#04110d"], ["#0e7490", "#ffffff"]),
+ scheme("Slate · fuchsia", "slate", ["#e879f9", "#1a0620"], ["#a21caf", "#ffffff"]),
+ scheme("Matrix · lime", "matrix", ["#a3e635", "#04170c"], ["#4d7c0f", "#ffffff"]),
+];
+
+const MIN_GAP = 30;
+
+function Metric({ ok, children }: { ok: boolean; children: ReactNode }) {
+ return (
+ {children}
+ );
+}
+
+function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
+ const p = s[mode];
+ const st = STATUS[mode];
+ const style = {
+ "--bg": p.bg,
+ "--surface": p.surface,
+ "--card": p.card,
+ "--border": p.border,
+ "--fg": p.fg,
+ "--muted": p.muted,
+ "--accent": p.accent,
+ "--accent-fg": p.accentFg,
+ "--danger": st.danger,
+ "--danger-fg": st.dangerFg,
+ "--warn": st.warn,
+ "--success": st.success,
+ } as CSSProperties;
+ const gaps = (["danger", "warn", "success"] as const).map((k) => ({
+ k,
+ g: Math.round(hueGap(p.accent, st[k])),
+ }));
+ const worst = gaps.reduce((a, b) => (b.g < a.g ? b : a));
+ const text = hexContrast(p.accent, p.bg);
+ const onFill = hexContrast(p.accentFg, p.accent);
+ return (
+
+
+ {s.name} · {mode}
+
+
+ Save
+ Delete
+
+
+ Filter
+ Retrying
+ Done
+
+
+ Text with a link in it.
+
+ {/* Measured from the hex values; drawn in the tile's own status colours. */}
+
+ = MIN_GAP}>{`closest status: ${worst.k} ${worst.g}°`}
+ = 4.5}>{`accent as text ${text.toFixed(2)}:1`}
+ = 4.5}>{`button text ${onFill.toFixed(2)}:1`}
+
+
+ );
+}
+
+function SchemeGrid({ set }: { set: SchemeDef[] }) {
+ return (
+
+ {set.flatMap((s) =>
+ (["dark", "light"] as const).map((m) => ),
+ )}
+
+ );
+}
+
+export function B2() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── B3 · state layers ────────────────────────────────────────────────────────────────────────────
+// A state is a layer of a colour mixed over whatever the control sits on, so it works on the page,
+// on a card and on glass alike. `bg-card` as a hover could not: on a white light-theme card it is the
+// card's own colour. Each control is shown at rest, hovered and pressed, then once live.
+
+type Layers = {
+ name: string;
+ /** static hover / pressed classes for the ghost & row sample */
+ gh: string;
+ gp: string;
+ /** static hover / pressed classes for the filled sample */
+ fh: string;
+ fp: string;
+ /** live classes */
+ ghostLive: string;
+ filledLive: string;
+ /** colour expressions for the measured readout */
+ ghostOnPage: string;
+ rowOnCard: string;
+ filledHover: string;
+};
+
+const mix = (c: string, p: number, over: string) => `color-mix(in srgb, var(${c}) ${p}%, ${over})`;
+
+const LAYERS: Record<"strong" | "material" | "today", Layers> = {
+ strong: {
+ name: "10% / 16%",
+ gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]",
+ gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]",
+ fh: "bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] text-accent-fg",
+ fp: "bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))] text-accent-fg",
+ ghostLive:
+ "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]",
+ filledLive:
+ "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]",
+ ghostOnPage: mix("--fg", 10, "var(--bg)"),
+ rowOnCard: mix("--fg", 10, "var(--card)"),
+ filledHover: mix("--accent-fg", 14, "var(--accent)"),
+ },
+ material: {
+ name: "8% / 12% (Material 3)",
+ gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_8%,transparent)]",
+ gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]",
+ fh: "bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] text-accent-fg",
+ fp: "bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))] text-accent-fg",
+ ghostLive:
+ "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_8%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]",
+ filledLive:
+ "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))]",
+ ghostOnPage: mix("--fg", 8, "var(--bg)"),
+ rowOnCard: mix("--fg", 8, "var(--card)"),
+ filledHover: mix("--accent-fg", 8, "var(--accent)"),
+ },
+ today: {
+ name: "today",
+ gh: "text-fg bg-card",
+ gp: "text-fg bg-card",
+ fh: "bg-accent text-accent-fg opacity-90",
+ fp: "bg-accent text-accent-fg opacity-90",
+ ghostLive: "text-muted hover:text-fg hover:bg-card",
+ filledLive: "btn-primary",
+ ghostOnPage: "var(--card)",
+ rowOnCard: "var(--card)",
+ filledHover: "color-mix(in srgb, var(--accent) 90%, var(--bg))",
+ },
+};
+
+const cell = "inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium";
+const HOVER_MIN = 1.15;
+
+function StateRow({
+ label,
+ rest,
+ hover,
+ pressed,
+}: {
+ label: string;
+ rest: string;
+ hover: string;
+ pressed: string;
+}) {
+ return (
+
+ {label}
+ Rest
+ Hover
+ Pressed
+
+ );
+}
+
+function LayerSet({ l }: { l: Layers }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ghost
+
+
+ Primary
+
+
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t) => (
+
+ {t}
+
+ ))}
+
+
+ >
+ );
+}
+
+export function B3() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/design-lab/batchBChrome.tsx b/frontend/src/design-lab/batchBChrome.tsx
new file mode 100644
index 00000000..3d650915
--- /dev/null
+++ b/frontend/src/design-lab/batchBChrome.tsx
@@ -0,0 +1,719 @@
+import clsx from "clsx";
+import type { ReactNode } from "react";
+import {
+ Bell,
+ Check,
+ ChevronDown,
+ Download,
+ Home,
+ ListVideo,
+ Loader2,
+ Lock,
+ PlayCircle,
+ Repeat,
+ Rss,
+ Settings,
+ SlidersHorizontal,
+ Tv,
+} from "lucide-react";
+import { Decision, Sample, Variant } from "./labKit";
+
+// E28 S2 · Batch B, part 2 — surfaces and page chrome: the page-chrome model, the surface per role,
+// the public watch page, the nav rail, the loading form, and where the header + fade edge goes.
+// The mini pages are real scroll boxes: scroll them to see what stays and what slides under.
+
+const ico = "w-4 h-4";
+
+/** The north-star edge (DataTable's sticky thead): a crisp band, and a short gradient of the colour
+ * it sits on just below it, so content dissolves as it slides under. Literal strings per colour —
+ * Tailwind only generates classes it can read whole. */
+const FADE_BELOW =
+ "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
+const FADE_BELOW_PANEL =
+ "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
+const FADE_ABOVE_PANEL =
+ "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
+
+function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) {
+ return (
+
+
+
+ );
+}
+
+function Cards({ n = 12 }: { n?: number }) {
+ return (
+
+ {Array.from({ length: n }, (_, i) => (
+
+
+
Video title number {i + 1}
+
Channel · 2 days ago
+
+ ))}
+
+ );
+}
+
+const tab = (on: boolean) =>
+ clsx(
+ "rounded-lg px-2.5 py-1 text-xs",
+ on ? "bg-accent text-accent-fg" : "text-muted hover:text-fg",
+ );
+const pillCtl = "glass-card inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs";
+
+function Tabs() {
+ return (
+
+ Queue
+ Library
+ History
+
+ );
+}
+
+function Filters() {
+ return (
+
+
+ State: Unwatched
+
+
+ Date: Any
+
+
+ Sort: Newest
+
+
+ );
+}
+
+// ── B4 · the page-chrome model ───────────────────────────────────────────────────────────────────
+function BandToolbar({ collapsed }: { collapsed?: boolean }) {
+ return (
+
+
+
+ {collapsed && (
+
+ Filters · 3
+
+ )}
+
+ {!collapsed &&
}
+
+ );
+}
+
+function GlassToolbar() {
+ return (
+
+ );
+}
+
+function InlineToolbar() {
+ return (
+
+
+
+
+ );
+}
+
+export function B4() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── B5 · surface per role ────────────────────────────────────────────────────────────────────────
+type Roles = { dialog: string; card: string; menu: string; item: string; tip: string };
+
+function Surfaces({ r }: { r: Roles }) {
+ return (
+
+
+
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t, i) => (
+
+ {t}
+
+ ))}
+
+
Tooltip
+
+
+ );
+}
+
+export function B5() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── B6 · the public watch page ───────────────────────────────────────────────────────────────────
+function WatchMock({ app }: { app: boolean }) {
+ const btn = app
+ ? "glass-card glass-hover rounded-lg h-8 px-3"
+ : "rounded-lg bg-surface hover:bg-card px-3 py-2";
+ return (
+
+
+
+ the browser's own video controls
+
+
+
+
Building a home lab on a budget
+
Techno Tim
+
+
+
+ Repeat
+
+
+ Download
+
+
+
+
+ );
+}
+
+export function B6() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── B7 · the nav rail ────────────────────────────────────────────────────────────────────────────
+const NAV = [Home, Rss, ListVideo, Tv, Download, Bell];
+
+function Rail({ kind }: { kind: "solid" | "soft" | "indicator" }) {
+ return (
+
+ {NAV.map((I, i) => {
+ const on = i === 1;
+ if (kind === "indicator")
+ return (
+
+
+
+
+
+ {["Home", "Feed", "Lists", "Plex", "Saved", "Alerts"][i]}
+
+
+ );
+ return (
+
+ {kind === "soft" && on && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+export function B7() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── B8 · the loading form ────────────────────────────────────────────────────────────────────────
+function SkeletonGrid() {
+ return (
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function SpinnerBlock() {
+ return (
+
+
+ Loading…
+
+
+ );
+}
+
+function SavingButton() {
+ return (
+
+ Saving
+
+ );
+}
+
+export function B8() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── B9 · where the fade edge goes ────────────────────────────────────────────────────────────────
+const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`);
+
+function ScrollBody({ children }: { children?: ReactNode }) {
+ return (
+
+ {children}
+ {LINES.map((l) => (
+
+ {l}
+
+ ))}
+
+ );
+}
+
+function FadeToolbar() {
+ return (
+
+
+ Title · Channel · Date
+
+
+
+ );
+}
+
+function FadeTabs() {
+ return (
+
+
+
+ );
+}
+
+function FadeButtons() {
+ const depth =
+ "bg-[linear-gradient(to_bottom,color-mix(in_srgb,#fff_14%,var(--accent)),var(--accent)_55%,color-mix(in_srgb,#000_14%,var(--accent)))] text-accent-fg shadow-[inset_0_-1px_0_rgb(0_0_0/0.25)]";
+ const glassDepth =
+ "glass-card bg-[linear-gradient(to_bottom,color-mix(in_srgb,var(--fg)_8%,transparent),transparent)]";
+ return (
+
+
+
+ Flat
+
+
+ Depth
+
+
+ Flat glass
+
+
+ Depth glass
+
+
+
+ );
+}
+
+function FadeChips() {
+ const mask =
+ "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)";
+ const chips = [
+ "Unwatched",
+ "Shorts",
+ "Live",
+ "Music",
+ "Tech",
+ "Science",
+ "News",
+ "Gaming",
+ "Travel",
+ ];
+ return (
+
+
+
+ {chips.map((c) => (
+
+ {c}
+
+ ))}
+
+
+
+ );
+}
+
+function FadeCard() {
+ return (
+
+
+ {[false, true].map((fade) => (
+
+
+ {fade && (
+
+ )}
+
+
+ {fade ? "Faded edge" : "Hard edge (today)"}
+
+
+ ))}
+
+
+ );
+}
+
+function FadeDialog() {
+ return (
+
+
+
+ Edit details
+
+
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+}
+
+export function B9() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/design-lab/labColor.ts b/frontend/src/design-lab/labColor.ts
new file mode 100644
index 00000000..fbc98764
--- /dev/null
+++ b/frontend/src/design-lab/labColor.ts
@@ -0,0 +1,33 @@
+// ── Fixed-palette maths ──────────────────────────────────────────────────────────────────────────
+// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on
+// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far
+// apart, unlike HSL's.
+
+const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
+const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
+
+export function hexContrast(a: string, b: string): number {
+ const l = (h: string) => {
+ const [r = 0, g = 0, bl = 0] = rgb(h).map(lin2);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
+ };
+ const x = l(a);
+ const y = l(b);
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
+}
+
+function hue(hex: string): number {
+ const [r = 0, g = 0, b = 0] = rgb(hex).map(lin2);
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
+ return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
+}
+
+/** The OKLCH hue angle between two colours, 0..180°. */
+export function hueGap(a: string, b: string): number {
+ const d = Math.abs(hue(a) - hue(b)) % 360;
+ return Math.min(d, 360 - d);
+}
diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx
index 473f96ec..4b08758a 100644
--- a/frontend/src/design-lab/labKit.tsx
+++ b/frontend/src/design-lab/labKit.tsx
@@ -77,6 +77,35 @@ export function Variant({
);
}
+/** 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. */
+export function Reference({
+ id,
+ code,
+ title,
+ rule,
+ children,
+}: {
+ id: string;
+ code: string;
+ title: string;
+ rule: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {code} ✓
+
{title}
+
+ {rule}
+
+ {children}
+
+ );
+}
+
/** A small caption above a sample inside a variant. */
export function Sample({ label, children }: { label: string; children: ReactNode }) {
return (
From 7055548260833f0d51ab016cf48897b89166e5da Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 03:10:00 +0200
Subject: [PATCH 004/137] 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.
---
docs/DESIGN.md | 104 ++-
frontend/src/design-lab/DesignLab.tsx | 30 +-
frontend/src/design-lab/batchA.tsx | 387 ++++------
frontend/src/design-lab/batchB.tsx | 887 +++++++++++-----------
frontend/src/design-lab/batchBChrome.tsx | 719 ------------------
frontend/src/design-lab/batchC.tsx | 269 +++++++
frontend/src/design-lab/batchCActions.tsx | 416 ++++++++++
frontend/src/design-lab/labTokens.ts | 36 +
8 files changed, 1403 insertions(+), 1445 deletions(-)
delete mode 100644 frontend/src/design-lab/batchBChrome.tsx
create mode 100644 frontend/src/design-lab/batchC.tsx
create mode 100644 frontend/src/design-lab/batchCActions.tsx
create mode 100644 frontend/src/design-lab/labTokens.ts
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index b0b27dff..b35442d8 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -58,38 +58,48 @@ style guide.
(A3, under Controls). [HIG, WCAG 2.5.8]
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
when an input gets focus.
-- **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**.
+- **Below 768px** (a narrow window, split screen): what replaces the side rails, and how a side panel opens on touch, is **Pending (C2)**. The Plex player on touch is **Pending (C3)**.
## Tokens
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
---warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark
- and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3,
- 1.4.11] _The scheme set and the light-theme accent are decision B2._ The input edge token is set under
- Controls (A4).
+--warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark
+ and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (lime). Each earns its
+ place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
+ [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4).
+- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
+ `--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on
+ the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
+ measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3]
+- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
+ (hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
+ alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
+ `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent.
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
px font size or a px-frozen box is an eslint error.
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
- form fields `rounded-lg`; toolbar triggers and search a pill (Controls). _Surfaces per role: decision B5._
+ form fields `rounded-lg`; toolbar triggers and search a pill (Controls). Surfaces: see Surfaces.
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
overlay < popover < tooltip`). No ad-hoc z-index. [maintainability]
-## Controls — _Decided_ (batch A; density **Pending**, B1)
+## Controls — _Decided_ (batches A and B)
The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3.
- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12,
dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel
- headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. _The rung
- heights (standard 38/32/26 or compact 32/28/24) are decision B1._ [HIG, Material]
-- **Icon buttons (A2):** two sizes, r8: the smaller in rows and dense toolbars, the larger on its own. On a
+ headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. [HIG, Material]
+- **Density (B1): compact, by explicit height.** `md` 2rem (32), `sm` 1.75rem (28), `xs` 1.5rem (24); icon
+ buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
+ the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
+- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only.
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
sizes don't change. [HIG, WCAG 2.5.8]
- **Input (A4):** one bordered input primitive. The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`,
- ≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height; a toolbar search
- is a pill. Text is 1rem on a coarse pointer. [WCAG 1.4.11]
+ ≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height (32); a toolbar search
+ is a pill at the `sm` height (28). Text is 1rem on a coarse pointer. [WCAG 1.4.11]
- **Dropdown trigger (A5):** a pill in a toolbar (where the pill means "a filter or a choice"), r8 in a form
(it lines up with the inputs).
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color`; the whole
@@ -105,13 +115,29 @@ Also in force:
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1]
-## Surfaces and page chrome — **Pending (batch B)**
+## Surfaces and page chrome — _Decided_ (batch B)
-Open in the lab: **B1** button density · **B2** the scheme set (and the light-theme accent) · **B3** state
-layers (hover, pressed) · **B4** the page-chrome model (a sticky band vs controls in the content, folding on
-a short screen, the page `h1`) · **B5** the surface and radius per role (card, dialog, menu, tooltip) · **B6**
-the public watch page · **B7** the nav rail · **B8** the loading form (spinner vs skeleton) · **B9** where
-the header + fade edge goes.
+- **Surface per role (B5):** a dialog or panel is `.glass` r16; a card or picker `.glass-card` r12; a menu
+ `.glass-menu` r12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip `.glass-menu` r8.
+ Translucency comes only from the tiers (the app backdrop, Plex media, light); a per-site alpha override is a
+ violation.
+- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
+ under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
+ a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
+- **The header + fade edge (B9):** a crisp band and a 1rem gradient of the colour it sits on, so content
+ dissolves as it slides under. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
+ long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
+ are the content) and not on buttons (it becomes a dated gloss).
+- **Nav rail (B7):** a `NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
+ 3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
+ height, never width, so each module has a short rail label that fits, and the full name stays in the
+ tooltip. No wide padding around the icons.
+- **Loading (B8):** a first load of a grid, list or table shows its skeleton (a `Skeleton` primitive), so
+ nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
+ before ~300ms. [Nielsen #1]
+- **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the
+ scheme's backdrop); only the video keeps the browser's own controls, because a stranger's device is
+ unknown.
Already in force:
@@ -134,7 +160,7 @@ Which actions are visible and which live in a menu.
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
Destructive ones keep their confirmation. [Nielsen #8, #5]
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. _Which actions stay
- visible per object is part of batch C._
+ visible per object is the classification table, **Pending review (C1)**._
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
@@ -167,12 +193,17 @@ Wherever there is text, the user can select it and copy it, and no click action
An error sits next to what failed and says how to recover; a field error is under its field and linked
with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1]
- **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one.
- Space is reserved for late content so the layout doesn't jump. _Spinner vs skeleton: decision B8._
+ Space is reserved for late content so the layout doesn't jump; the form is under Surfaces (B8).
- **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom),
and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7]
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
+## Dialogs — **Pending (C4)**
+
+Where focus lands when a dialog opens (by purpose, APG), and whether the footer stays pinned while the body
+scrolls. Open in the lab.
+
## Copy
- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
@@ -190,15 +221,24 @@ Wherever there is text, the user can select it and copy it, and no click action
## Decision log
-| Date | Decision | Chosen | Over | Why |
-| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------- |
-| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
-| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
-| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
-| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
-| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
-| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
-| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
-| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
-| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
-| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
+| Date | Decision | Chosen | Over | Why |
+| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
+| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
+| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
+| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
+| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
+| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
+| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
+| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
+| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
+| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
+| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
+| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
+| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
+| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
+| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
+| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
+| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
+| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
+| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index 4101fdd8..d4e756a5 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -2,20 +2,16 @@ import { useLayoutEffect, useState } from "react";
import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
-import { B1, B2, B3 } from "./batchB";
-import { B4, B5, B6, B7, B8, B9 } from "./batchBChrome";
+import { BatchB } from "./batchB";
+import { C1 } from "./batchCActions";
+import { C2, C3, C4 } from "./batchC";
// The open decisions, in the order they are asked. The decided ones render below them as reference.
const OPEN = [
- { id: "b1", code: "B1", title: "Density", render: B1 },
- { id: "b2", code: "B2", title: "Schemes", render: B2 },
- { id: "b3", code: "B3", title: "Hover", render: B3 },
- { id: "b4", code: "B4", title: "Page chrome", render: B4 },
- { id: "b5", code: "B5", title: "Surfaces", render: B5 },
- { id: "b6", code: "B6", title: "/watch", render: B6 },
- { id: "b7", code: "B7", title: "Nav rail", render: B7 },
- { id: "b8", code: "B8", title: "Loading", render: B8 },
- { id: "b9", code: "B9", title: "Fade", render: B9 },
+ { id: "c1", code: "C1", title: "Action placement", render: C1 },
+ { id: "c2", code: "C2", title: "Side panels", render: C2 },
+ { id: "c3", code: "C3", title: "Player touch", render: C3 },
+ { id: "c4", code: "C4", title: "Dialogs", render: C4 },
] as const;
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
@@ -115,12 +111,15 @@ export default function DesignLab() {
A ✓
+
+ B ✓
+
- Batch B · surfaces, chrome and colour
+ Batch C · behaviour and device
Each decision shows its variants with the app's real CSS. The framed one is the
recommendation. Resize the window to tablet size to see them there too.
@@ -136,6 +135,13 @@ export default function DesignLab() {
+
+
);
diff --git a/frontend/src/design-lab/batchA.tsx b/frontend/src/design-lab/batchA.tsx
index c83ab7dc..4009c01d 100644
--- a/frontend/src/design-lab/batchA.tsx
+++ b/frontend/src/design-lab/batchA.tsx
@@ -1,5 +1,5 @@
import clsx from "clsx";
-import type { CSSProperties, ReactNode } from "react";
+import type { ReactNode } from "react";
import {
ChevronDown,
Download,
@@ -12,39 +12,30 @@ import {
Trash2,
} from "lucide-react";
import { Contrast, Reference, Sample } from "./labKit";
+import { DANGER, FILLED, H, LAYER, STRONG } from "./labTokens";
-// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended). Only the chosen
-// rendering of each stays, as the reference the S3 primitives are built to; the losing variants were
-// deleted with the pick. The markup uses the app's real CSS classes and is wired to no data.
+// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended), rendered with the
+// batch B decisions that refine them: B1's compact ladder and B3's state layers. Only the chosen
+// rendering of each stays, as the reference the S3 primitives are built to. Wired to no data.
-// ── shared proposal pieces ─────────────────────────────────────────────────────────────────────
type Tone = "primary" | "ghost" | "outline" | "glass" | "danger";
-type Size = "xs" | "sm" | "md";
-type Radius = "rounded-lg" | "rounded-xl" | "rounded-full";
const TONE: Record = {
- primary: "font-medium btn-primary",
- ghost: "font-medium text-muted hover:text-fg hover:bg-card",
- outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent",
- glass: "font-medium glass-card glass-hover hover:text-accent",
- danger: "font-medium btn-danger",
-};
-const SIZE: Record = {
- xs: "px-2 py-1 text-xs gap-1",
- sm: "px-3 py-1.5 text-sm gap-1.5",
- md: "px-3 py-2 text-sm gap-1.5",
+ primary: FILLED,
+ ghost: clsx("text-muted hover:text-fg", LAYER),
+ outline: clsx("border border-border text-muted hover:text-fg", LAYER),
+ glass: clsx("glass-card glass-hover hover:text-accent"),
+ danger: DANGER,
};
-function Btn({
+export function Btn({
tone,
size,
- radius,
icon,
children,
}: {
tone: Tone;
- size: Size;
- radius: Radius;
+ size: "xs" | "sm" | "md";
icon?: ReactNode;
children: ReactNode;
}) {
@@ -52,10 +43,9 @@ function Btn({
{icon}
@@ -66,54 +56,14 @@ function Btn({
const ico = "w-4 h-4";
-// ── A1 · button shape and ladder ──────────────────────────────────────────────────────────────────
-function ButtonSet({ radius }: { radius: Radius }) {
- return (
- <>
-
-
- }>
- Sync now
-
-
- Full history
-
-
-
-
-
-
- Cancel
-
- }>
- Delete
-
-
- Save
-
-
-
-
-
- Download failed · HTTP 403
-
- Retry
-
-
-
- >
- );
-}
-
-// ── A2 · icon buttons ─────────────────────────────────────────────────────────────────────────────
-function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) {
+function IconBtn({ box, children }: { box: string; children: ReactNode }) {
return (
{children}
@@ -121,13 +71,10 @@ function Icon({ box, radius, children }: { box: string; radius: string; children
);
}
-function OnMedia({ radius }: { radius: string }) {
- const b = clsx(
- "grid h-8 w-8 place-items-center bg-black/55 text-white hover:bg-black/75",
- radius,
- );
+function OnMedia() {
+ const b = "grid h-8 w-8 place-items-center rounded-full bg-black/55 text-white hover:bg-black/75";
return (
-
+
@@ -140,133 +87,36 @@ function OnMedia({ radius }: { radius: string }) {
);
}
-function IconSet({ row, bar, media }: { row: string; bar: string; media: string }) {
- const r = row.includes("full") ? "rounded-full" : "rounded-lg";
+function TouchRow() {
+ const pill = "inline-flex min-h-11 items-center px-3 text-sm";
return (
- <>
-
-
-
Techno Tim
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-// ── A3 · the touch rule beyond Button ────────────────────────────────────────────────────────────
-function TouchRow({ h, gap }: { h: string; gap: string }) {
- const pill = clsx("inline-flex items-center px-3 text-sm", h);
- return (
-
-
-
-
- Mine
- Library
-
-
- Sort: Newest
-
-
-
-
-
-
- Queue
- Library
-
-
-
-
- Dark mode
-
-
-
-
-
-
+
+
+
+ Mine
+ Library
+
+
+ Sort: Newest
+
+
+
+
+ Dark mode
+
+
+
+
);
}
-// ── A4 · the input ───────────────────────────────────────────────────────────────────────────────
-// The proposed stronger border: the text colour mixed into the page background, opaque, so its
-// contrast is the same on glass and on flat surfaces. Measured over all 12 scheme × mode pairs:
-// 45% fails (light/forest 2.68), 50% scrapes by (3.06), 55% clears 3:1 with margin (worst 3.52).
-const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
-
-function Field({
- cls,
- style,
- searchCls,
- searchStyle,
-}: {
- cls: string;
- style?: CSSProperties;
- searchCls: string;
- searchStyle?: CSSProperties;
-}) {
- return (
- <>
-
-
-
-
-
-
-
-
- Playlist name
-
- A playlist with this name already exists.
-
-
- >
- );
-}
-
-// ── A5 · dropdown trigger ────────────────────────────────────────────────────────────────────────
-function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) {
+function Trigger({ label, value }: { label: string; value: string }) {
return (
{label}: {value}
@@ -275,40 +125,15 @@ function Trigger({ cls, label, value }: { cls: string; label: string; value: str
);
}
-function DropdownSet({ bar, form }: { bar: string; form: string }) {
- return (
- <>
-
-
-
-
-
-
-
-
-
- Download format
-
- MP4 · 1080p
-
-
-
-
- >
- );
-}
-
-// ── A6 · checkbox, switch, slider ────────────────────────────────────────────────────────────────
function NativeSet() {
+ const row = clsx("flex min-h-11 items-center gap-3 rounded-lg px-2 text-sm", LAYER);
return (
<>
-
+
Only unwatched
-
+
Hide Shorts
@@ -333,40 +158,115 @@ export function BatchA() {
-
+
+
+
+
+
+
+
+ }>
+ Sync now
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ }>
+ Delete
+
+
+ Save
+
+
+
+
+
+ Download failed · HTTP 403
+
+ Retry
+
+
+
-
+
+
+
Techno Tim
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+ Playlist name
+
+ A playlist with this name already exists.
+
-
+
+
+
+
+
+ Download format
+
+ MP4 · 1080p
+
+
+
- {icon}
- {children}
-
- );
-}
-
-const GHOST = "text-muted hover:text-fg hover:bg-card";
-const OUTLINE = "border border-border text-muted hover:text-accent hover:border-accent";
-const GLASS = "glass-card glass-hover hover:text-accent";
-
-function IconBtn({ box, children }: { box: string; children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-function DensitySet({ l }: { l: Ladder }) {
- return (
- <>
-
-
-
-
-
-
-
- Sort: Newest
-
-
- }>
- Sync now
-
-
-
-
-
-
-
-
-
- Playlist name
-
-
-
-
- Cancel
-
- }>
- Delete
-
-
- Save
-
-
-
-
-
-
-
Download failed · HTTP 403
-
-
- Retry
-
-
-
-
-
-
-
-
-
-
-
-
- }>
- Download
-
-
-
-
-
-
- >
- );
-}
-
-export function B1() {
- return (
-
-
-
-
-
-
-
-
- );
-}
// ── B2 · the scheme set ──────────────────────────────────────────────────────────────────────────
-// A proposed scheme is rendered by scoping the tokens on a wrapper: every utility resolves var(--…)
-// at the use site (the `@theme inline` block in index.css), so bg-accent inside the tile paints the
-// tile's accent, not the page's. The numbers under a tile come from the hex values, not the probe.
+// A scheme that is not in index.css yet is rendered by scoping its tokens on a wrapper: every
+// utility resolves var(--…) at the use site (`@theme inline`), so the tile paints its own accent.
type Pal = {
bg: string;
surface: string;
- card: string;
border: string;
fg: string;
muted: string;
@@ -231,77 +63,37 @@ const STATUS: Record ({ bg, surface, card, border, fg, muted, accent, accentFg });
+): Pal => ({ bg, surface, border, fg, muted, accent, accentFg });
-// Today's six, copied from index.css (the scheme blocks).
-const N = {
- midnight: {
- dark: ["#0b1020", "#121a2e", "#161f38", "#243049", "#e6e9f0", "#97a3c0"],
- light: ["#f4f6fc", "#ffffff", "#ffffff", "#dde3f0", "#1a2238", "#5b6685"],
+// Backgrounds and text are today's (index.css); only the accents are new.
+const SCHEMES: SchemeDef[] = [
+ {
+ name: "Midnight · blue",
+ dark: pal("#0b1020", "#121a2e", "#243049", "#e6e9f0", "#97a3c0", "#6d8cff", "#0b1020"),
+ light: pal("#f4f6fc", "#ffffff", "#dde3f0", "#1a2238", "#5b6685", "#3b5bdb", "#ffffff"),
},
- forest: {
- dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"],
- light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"],
+ {
+ name: "Forest · cyan",
+ dark: pal("#0a1512", "#0f1f1a", "#1f3a30", "#e6f0ec", "#90b1a4", "#22d3ee", "#04110d"),
+ light: pal("#f1f7f4", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60", "#0e7490", "#ffffff"),
},
- slate: {
- dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"],
- light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"],
+ {
+ name: "Slate · fuchsia",
+ dark: pal("#15171c", "#1b1e25", "#2c313b", "#e7e9ee", "#9aa1ad", "#e879f9", "#1a0620"),
+ light: pal("#f6f7f9", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e", "#a21caf", "#ffffff"),
},
- youtube: {
- dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"],
- light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"],
+ {
+ name: "Matrix · lime",
+ dark: pal("#030a06", "#08130d", "#163d27", "#cfe8d6", "#6fae86", "#a3e635", "#04170c"),
+ light: pal("#f0f7f1", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56", "#4d7c0f", "#ffffff"),
},
- starship: {
- dark: ["#050b16", "#0b1728", "#101f36", "#1f3557", "#dfeaff", "#8aa6cc"],
- light: ["#eef4fb", "#ffffff", "#ffffff", "#d3e0f0", "#0b1b30", "#4a5f7d"],
- },
- matrix: {
- dark: ["#030a06", "#08130d", "#0b1c12", "#163d27", "#cfe8d6", "#6fae86"],
- light: ["#f0f7f1", "#ffffff", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56"],
- },
-} as const;
-
-function scheme(
- name: string,
- id: keyof typeof N,
- dark: [string, string],
- light: [string, string],
-): SchemeDef {
- const [db, ds, dc, dbo, df, dm] = N[id].dark;
- const [lb, ls, lc, lbo, lf, lm] = N[id].light;
- return {
- name,
- dark: pal(db, ds, dc, dbo, df, dm, dark[0], dark[1]),
- light: pal(lb, ls, lc, lbo, lf, lm, light[0], light[1]),
- };
-}
-
-const TODAY: SchemeDef[] = [
- scheme("Midnight", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
- scheme("Forest", "forest", ["#2dd4bf", "#04110d"], ["#0d9488", "#ffffff"]),
- scheme("Slate", "slate", ["#e8913c", "#1a1206"], ["#d97706", "#ffffff"]),
- scheme("YouTube", "youtube", ["#ff3b46", "#ffffff"], ["#ff0033", "#ffffff"]),
- scheme("Starship", "starship", ["#38b6ff", "#04101f"], ["#0b6bd6", "#ffffff"]),
- scheme("Matrix", "matrix", ["#46b87c", "#04170c"], ["#12a150", "#ffffff"]),
];
-// The proposal: four schemes, four hue families far from each other and from red / amber / green.
-// Only the accents move; every background and text colour stays as it is.
-const PROPOSED: SchemeDef[] = [
- scheme("Midnight · blue", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
- scheme("Forest · cyan", "forest", ["#22d3ee", "#04110d"], ["#0e7490", "#ffffff"]),
- scheme("Slate · fuchsia", "slate", ["#e879f9", "#1a0620"], ["#a21caf", "#ffffff"]),
- scheme("Matrix · lime", "matrix", ["#a3e635", "#04170c"], ["#4d7c0f", "#ffffff"]),
-];
-
-const MIN_GAP = 30;
-
function Metric({ ok, children }: { ok: boolean; children: ReactNode }) {
return (
{children}
@@ -314,7 +106,7 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
const style = {
"--bg": p.bg,
"--surface": p.surface,
- "--card": p.card,
+ "--card": p.surface,
"--border": p.border,
"--fg": p.fg,
"--muted": p.muted,
@@ -325,11 +117,9 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
"--warn": st.warn,
"--success": st.success,
} as CSSProperties;
- const gaps = (["danger", "warn", "success"] as const).map((k) => ({
- k,
- g: Math.round(hueGap(p.accent, st[k])),
- }));
- const worst = gaps.reduce((a, b) => (b.g < a.g ? b : a));
+ const worst = (["danger", "warn", "success"] as const)
+ .map((k) => ({ k, g: Math.round(hueGap(p.accent, st[k])) }))
+ .reduce((a, b) => (b.g < a.g ? b : a));
const text = hexContrast(p.accent, p.bg);
const onFill = hexContrast(p.accentFg, p.accent);
return (
@@ -349,12 +139,8 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
Retrying
Done
-
- Text with a link in it.
-
- {/* Measured from the hex values; drawn in the tile's own status colours. */}
- = MIN_GAP}>{`closest status: ${worst.k} ${worst.g}°`}
+ = 30}>{`closest status: ${worst.k} ${worst.g}°`}
= 4.5}>{`accent as text ${text.toFixed(2)}:1`}
= 4.5}>{`button text ${onFill.toFixed(2)}:1`}
@@ -362,204 +148,413 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
);
}
-function SchemeGrid({ set }: { set: SchemeDef[] }) {
+// ── B4 · the page-chrome band ────────────────────────────────────────────────────────────────────
+function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) {
return (
-
- {set.flatMap((s) =>
- (["dark", "light"] as const).map((m) =>
),
+
+
+
+ );
+}
+
+function Cards() {
+ return (
+
+ {Array.from({ length: 12 }, (_, i) => (
+
+
+
Video title number {i + 1}
+
Channel · 2 days ago
+
+ ))}
+
+ );
+}
+
+const tab = (on: boolean) =>
+ clsx(
+ "rounded-lg px-2.5 py-1 text-xs",
+ on ? "bg-accent text-accent-fg" : clsx("text-muted hover:text-fg", LAYER),
+ );
+const pillCtl = clsx(
+ "glass-card inline-flex items-center gap-1 rounded-full border px-2.5 text-xs",
+ H.bar,
+);
+
+function Tabs() {
+ return (
+
+ Queue
+ Library
+ History
+
+ );
+}
+
+function Band({ collapsed }: { collapsed?: boolean }) {
+ return (
+
+
+
+ {collapsed && (
+
+ Filters · 3
+
+ )}
+
+ {!collapsed && (
+
+ {["State: Unwatched", "Date: Any", "Sort: Newest"].map((f) => (
+
+ {f}
+
+ ))}
+
)}
);
}
-export function B2() {
+// ── B5 · surface per role ────────────────────────────────────────────────────────────────────────
+function Surfaces() {
return (
-
-
-
-
-
-
-
-
- );
-}
-
-// ── B3 · state layers ────────────────────────────────────────────────────────────────────────────
-// A state is a layer of a colour mixed over whatever the control sits on, so it works on the page,
-// on a card and on glass alike. `bg-card` as a hover could not: on a white light-theme card it is the
-// card's own colour. Each control is shown at rest, hovered and pressed, then once live.
-
-type Layers = {
- name: string;
- /** static hover / pressed classes for the ghost & row sample */
- gh: string;
- gp: string;
- /** static hover / pressed classes for the filled sample */
- fh: string;
- fp: string;
- /** live classes */
- ghostLive: string;
- filledLive: string;
- /** colour expressions for the measured readout */
- ghostOnPage: string;
- rowOnCard: string;
- filledHover: string;
-};
-
-const mix = (c: string, p: number, over: string) => `color-mix(in srgb, var(${c}) ${p}%, ${over})`;
-
-const LAYERS: Record<"strong" | "material" | "today", Layers> = {
- strong: {
- name: "10% / 16%",
- gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]",
- gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]",
- fh: "bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] text-accent-fg",
- fp: "bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))] text-accent-fg",
- ghostLive:
- "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]",
- filledLive:
- "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]",
- ghostOnPage: mix("--fg", 10, "var(--bg)"),
- rowOnCard: mix("--fg", 10, "var(--card)"),
- filledHover: mix("--accent-fg", 14, "var(--accent)"),
- },
- material: {
- name: "8% / 12% (Material 3)",
- gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_8%,transparent)]",
- gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]",
- fh: "bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] text-accent-fg",
- fp: "bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))] text-accent-fg",
- ghostLive:
- "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_8%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]",
- filledLive:
- "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))]",
- ghostOnPage: mix("--fg", 8, "var(--bg)"),
- rowOnCard: mix("--fg", 8, "var(--card)"),
- filledHover: mix("--accent-fg", 8, "var(--accent)"),
- },
- today: {
- name: "today",
- gh: "text-fg bg-card",
- gp: "text-fg bg-card",
- fh: "bg-accent text-accent-fg opacity-90",
- fp: "bg-accent text-accent-fg opacity-90",
- ghostLive: "text-muted hover:text-fg hover:bg-card",
- filledLive: "btn-primary",
- ghostOnPage: "var(--card)",
- rowOnCard: "var(--card)",
- filledHover: "color-mix(in srgb, var(--accent) 90%, var(--bg))",
- },
-};
-
-const cell = "inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium";
-const HOVER_MIN = 1.15;
-
-function StateRow({
- label,
- rest,
- hover,
- pressed,
-}: {
- label: string;
- rest: string;
- hover: string;
- pressed: string;
-}) {
- return (
-
-
{label}
-
Rest
-
Hover
-
Pressed
+
+
+
Dialog · .glass r16
+
+
Card · r12
+
Picker · r12
+
+
+
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t) => (
+
+ {t}
+
+ ))}
+
+
Tooltip · .glass-menu r8
+
);
}
-function LayerSet({ l }: { l: Layers }) {
+// ── B6 · the public watch page ───────────────────────────────────────────────────────────────────
+function WatchPage() {
+ return (
+
+
+
+ the browser's own video controls
+
+
+
+
Building a home lab on a budget
+
Techno Tim
+
+
+
+ Repeat
+
+ }>
+ Download
+
+
+
+
+ );
+}
+
+// ── B7 · the nav rail (the owner's combination) ──────────────────────────────────────────────────
+// A soft tint + an edge bar for "you are here"; labels optional (a per-user Settings switch). Slim: the
+// rail is 3.75rem wide in BOTH modes (labels add height, never width), so each module needs a short
+// rail label that fits the item; the full name stays in the hover tooltip. Each rail reports whether
+// all its labels fit, measured live (it re-measures on the lab's text-size slider).
+
+type NavDef = { icon: typeof Home; en: string; hu: string };
+const NAV: NavDef[] = [
+ { icon: Home, en: "Home", hu: "Kezdőlap" },
+ { icon: Youtube, en: "Feed", hu: "Feed" },
+ { icon: Tv, en: "Channels", hu: "Csatornák" },
+ { icon: ListVideo, en: "Playlists", hu: "Listák" },
+ { icon: Clapperboard, en: "Plex", hu: "Plex" },
+ { icon: Bell, en: "Alerts", hu: "Értesítés" },
+ { icon: MessageSquare, en: "Messages", hu: "Üzenetek" },
+ { icon: Download, en: "Downloads", hu: "Letöltések" },
+];
+const SYSTEM: NavDef[] = [
+ { icon: BarChart3, en: "Stats", hu: "Statisztika" },
+ { icon: Users, en: "Users", hu: "Fiókok" },
+ { icon: Settings, en: "Settings", hu: "Beállítások" },
+];
+
+function RailItem({ d, on, lang }: { d: NavDef; on: boolean; lang?: "en" | "hu" }) {
+ const I = d.icon;
+ return (
+
+ {on && (
+
+ )}
+
+ {lang && (
+
+ {d[lang]}
+
+ )}
+
+ );
+}
+
+function Rail({ lang }: { lang?: "en" | "hu" }) {
+ const ref = useRef
(null);
+ const [clipped, setClipped] = useState([]);
+ useLayoutEffect(() => {
+ const measure = () =>
+ setClipped(
+ [...(ref.current?.querySelectorAll("[data-rail-label]") ?? [])]
+ .filter((e) => e.scrollWidth > e.clientWidth)
+ .map((e) => e.textContent ?? ""),
+ );
+ measure();
+ const mo = new MutationObserver(measure);
+ mo.observe(document.documentElement, { attributes: true });
+ return () => mo.disconnect();
+ }, []);
+ return (
+
+
+ {NAV.map((d, i) => (
+
+ ))}
+
+ {SYSTEM.map((d) => (
+
+ ))}
+
+ {lang && (
+
+ {clipped.length ? `clipped: ${clipped.join(", ")}` : "every label fits ✓"}
+
+ )}
+
+ );
+}
+
+// ── B8 · loading ─────────────────────────────────────────────────────────────────────────────────
+const bone = "animate-pulse rounded bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]";
+
+function Loading() {
return (
<>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Ghost
-
-
- Primary
-
-
-
- {["Watch later", "Add to playlist", "Copy link"].map((t) => (
-
- {t}
-
+
+
+ {Array.from({ length: 3 }, (_, i) => (
+
))}
+
+
+ Saving
+
+
>
);
}
-export function B3() {
+// ── B9 · the fade edges (1–4 chosen) ─────────────────────────────────────────────────────────────
+const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`);
+
+function ScrollBody() {
return (
-
-
+ {LINES.map((l) => (
+
+ {l}
+
+ ))}
+
+ );
+}
+
+const CHIPS = ["Unwatched", "Shorts", "Live", "Music", "Tech", "Science", "News", "Gaming"];
+
+function Fades() {
+ return (
+
+
+
+ Title · Channel · Date
+
+
+
+
+
+
+
+
+
+ Edit details
+
+
+
+
+
+
+ Cancel
+
+ }>
+ Save
+
+
+
+
+
+
+
+ {CHIPS.map((c) => (
+
+ {c}
+
+ ))}
+
+
+
+
+ );
+}
+
+export function BatchB() {
+ return (
+
+
-
-
-
+ {SCHEMES.flatMap((s) =>
+ (["dark", "light"] as const).map((m) => ),
+ )}
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/design-lab/batchBChrome.tsx b/frontend/src/design-lab/batchBChrome.tsx
deleted file mode 100644
index 3d650915..00000000
--- a/frontend/src/design-lab/batchBChrome.tsx
+++ /dev/null
@@ -1,719 +0,0 @@
-import clsx from "clsx";
-import type { ReactNode } from "react";
-import {
- Bell,
- Check,
- ChevronDown,
- Download,
- Home,
- ListVideo,
- Loader2,
- Lock,
- PlayCircle,
- Repeat,
- Rss,
- Settings,
- SlidersHorizontal,
- Tv,
-} from "lucide-react";
-import { Decision, Sample, Variant } from "./labKit";
-
-// E28 S2 · Batch B, part 2 — surfaces and page chrome: the page-chrome model, the surface per role,
-// the public watch page, the nav rail, the loading form, and where the header + fade edge goes.
-// The mini pages are real scroll boxes: scroll them to see what stays and what slides under.
-
-const ico = "w-4 h-4";
-
-/** The north-star edge (DataTable's sticky thead): a crisp band, and a short gradient of the colour
- * it sits on just below it, so content dissolves as it slides under. Literal strings per colour —
- * Tailwind only generates classes it can read whole. */
-const FADE_BELOW =
- "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
-const FADE_BELOW_PANEL =
- "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
-const FADE_ABOVE_PANEL =
- "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
-
-function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) {
- return (
-
-
-
- );
-}
-
-function Cards({ n = 12 }: { n?: number }) {
- return (
-
- {Array.from({ length: n }, (_, i) => (
-
-
-
Video title number {i + 1}
-
Channel · 2 days ago
-
- ))}
-
- );
-}
-
-const tab = (on: boolean) =>
- clsx(
- "rounded-lg px-2.5 py-1 text-xs",
- on ? "bg-accent text-accent-fg" : "text-muted hover:text-fg",
- );
-const pillCtl = "glass-card inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs";
-
-function Tabs() {
- return (
-
- Queue
- Library
- History
-
- );
-}
-
-function Filters() {
- return (
-
-
- State: Unwatched
-
-
- Date: Any
-
-
- Sort: Newest
-
-
- );
-}
-
-// ── B4 · the page-chrome model ───────────────────────────────────────────────────────────────────
-function BandToolbar({ collapsed }: { collapsed?: boolean }) {
- return (
-
-
-
- {collapsed && (
-
- Filters · 3
-
- )}
-
- {!collapsed &&
}
-
- );
-}
-
-function GlassToolbar() {
- return (
-
- );
-}
-
-function InlineToolbar() {
- return (
-
-
-
-
- );
-}
-
-export function B4() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// ── B5 · surface per role ────────────────────────────────────────────────────────────────────────
-type Roles = { dialog: string; card: string; menu: string; item: string; tip: string };
-
-function Surfaces({ r }: { r: Roles }) {
- return (
-
-
-
-
- {["Watch later", "Add to playlist", "Copy link"].map((t, i) => (
-
- {t}
-
- ))}
-
-
Tooltip
-
-
- );
-}
-
-export function B5() {
- return (
-
-
-
-
-
-
-
-
- );
-}
-
-// ── B6 · the public watch page ───────────────────────────────────────────────────────────────────
-function WatchMock({ app }: { app: boolean }) {
- const btn = app
- ? "glass-card glass-hover rounded-lg h-8 px-3"
- : "rounded-lg bg-surface hover:bg-card px-3 py-2";
- return (
-
-
-
- the browser's own video controls
-
-
-
-
Building a home lab on a budget
-
Techno Tim
-
-
-
- Repeat
-
-
- Download
-
-
-
-
- );
-}
-
-export function B6() {
- return (
-
-
-
-
-
-
-
-
- );
-}
-
-// ── B7 · the nav rail ────────────────────────────────────────────────────────────────────────────
-const NAV = [Home, Rss, ListVideo, Tv, Download, Bell];
-
-function Rail({ kind }: { kind: "solid" | "soft" | "indicator" }) {
- return (
-
- {NAV.map((I, i) => {
- const on = i === 1;
- if (kind === "indicator")
- return (
-
-
-
-
-
- {["Home", "Feed", "Lists", "Plex", "Saved", "Alerts"][i]}
-
-
- );
- return (
-
- {kind === "soft" && on && (
-
- )}
-
-
- );
- })}
-
-
-
-
-
- );
-}
-
-export function B7() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// ── B8 · the loading form ────────────────────────────────────────────────────────────────────────
-function SkeletonGrid() {
- return (
-
- {Array.from({ length: 6 }, (_, i) => (
-
- ))}
-
- );
-}
-
-function SpinnerBlock() {
- return (
-
-
- Loading…
-
-
- );
-}
-
-function SavingButton() {
- return (
-
- Saving
-
- );
-}
-
-export function B8() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-// ── B9 · where the fade edge goes ────────────────────────────────────────────────────────────────
-const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`);
-
-function ScrollBody({ children }: { children?: ReactNode }) {
- return (
-
- {children}
- {LINES.map((l) => (
-
- {l}
-
- ))}
-
- );
-}
-
-function FadeToolbar() {
- return (
-
-
- Title · Channel · Date
-
-
-
- );
-}
-
-function FadeTabs() {
- return (
-
-
-
- );
-}
-
-function FadeButtons() {
- const depth =
- "bg-[linear-gradient(to_bottom,color-mix(in_srgb,#fff_14%,var(--accent)),var(--accent)_55%,color-mix(in_srgb,#000_14%,var(--accent)))] text-accent-fg shadow-[inset_0_-1px_0_rgb(0_0_0/0.25)]";
- const glassDepth =
- "glass-card bg-[linear-gradient(to_bottom,color-mix(in_srgb,var(--fg)_8%,transparent),transparent)]";
- return (
-
-
-
- Flat
-
-
- Depth
-
-
- Flat glass
-
-
- Depth glass
-
-
-
- );
-}
-
-function FadeChips() {
- const mask =
- "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)";
- const chips = [
- "Unwatched",
- "Shorts",
- "Live",
- "Music",
- "Tech",
- "Science",
- "News",
- "Gaming",
- "Travel",
- ];
- return (
-
-
-
- {chips.map((c) => (
-
- {c}
-
- ))}
-
-
-
- );
-}
-
-function FadeCard() {
- return (
-
-
- {[false, true].map((fade) => (
-
-
- {fade && (
-
- )}
-
-
- {fade ? "Faded edge" : "Hard edge (today)"}
-
-
- ))}
-
-
- );
-}
-
-function FadeDialog() {
- return (
-
-
-
- Edit details
-
-
-
-
-
-
- Cancel
-
-
- Save
-
-
-
-
- );
-}
-
-export function B9() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx
new file mode 100644
index 00000000..094ec1cd
--- /dev/null
+++ b/frontend/src/design-lab/batchC.tsx
@@ -0,0 +1,269 @@
+import clsx from "clsx";
+import type { ReactNode } from "react";
+import { Maximize, Pause, Pin, SkipForward, Subtitles, Volume2, X } from "lucide-react";
+import { Btn } from "./batchA";
+import { Decision, Sample, Variant } from "./labKit";
+import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens";
+
+// E28 S2 · Batch C, part 2 — behaviour on touch and on a narrow window: the side panels, the player,
+// the dialog conventions. These are behaviours, so each variant is a still of the moment that
+// differs, with the sequence written under it.
+
+const ico = "w-4 h-4";
+
+function Screen({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function Content({ cols = 3 }: { cols?: number }) {
+ return (
+
+ {Array.from({ length: cols * 3 }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function FilterPanel({ pinnable }: { pinnable?: boolean }) {
+ return (
+
+
+
Filters
+
+ {pinnable && (
+
+
+
+ )}
+
+
+
+
+
+ {["Unwatched", "Shorts hidden", "Last 30 days", "Tech", "Science"].map((f) => (
+
+ {f}
+
+ ))}
+
+ );
+}
+
+// ── C2 · side panels on touch and on a narrow window ─────────────────────────────────────────────
+export function C2() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── C3 · the player on touch ─────────────────────────────────────────────────────────────────────
+function Player({ touch }: { touch: boolean }) {
+ const btn = touch ? "h-11 w-11" : "h-8 w-8";
+ return (
+
+
+ {/* The seek bar: the visible track stays thin; on touch the hit area around it is 44 tall. */}
+
+
+ {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
+
+
+
+ ))}
+ 12:04 / 31:40
+
+
+
+
+
+
+ );
+}
+
+export function C3() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── C4 · dialog conventions ──────────────────────────────────────────────────────────────────────
+function Field({ label, value, focused }: { label: string; value: string; focused?: boolean }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+function ShortDialog({ pinned }: { pinned: boolean }) {
+ const fields = (
+
+
+
+
+
+ {!pinned && (
+
+
+ Cancel
+
+
+ Save
+
+
+ )}
+
+ );
+ return (
+
+
+ Edit details
+
+
+
+
+
{fields}
+ {pinned && (
+
+
+ Cancel
+
+
+ Save
+
+
+ )}
+
+ );
+}
+
+export function C4() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/design-lab/batchCActions.tsx b/frontend/src/design-lab/batchCActions.tsx
new file mode 100644
index 00000000..7bc9ebbe
--- /dev/null
+++ b/frontend/src/design-lab/batchCActions.tsx
@@ -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
= {
+ rest: "● at rest",
+ hover: "◐ on hover",
+ menu: "☰ menu",
+};
+
+function Table() {
+ return (
+
+
+
+
+ Action
+ Mouse
+ Touch
+ Change against today
+
+
+ {TABLE.map((g) => (
+
+
+
+ {g.object}
+
+
+ {g.rows.map((r) => (
+
+
+ {r.action}
+ {r.danger && " ⚠"}
+
+ {MOUSE[r.mouse]}
+
+ {r.touch === "visible" ? "● visible" : "… menu"}
+
+ {r.change}
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ 12:04 left
+
+ {!touch && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ Building a home lab on a budget: Proxmox, ZFS and a cheap NAS
+
+
Techno Tim · 2 days ago
+
+ {touch && (
+
+
+
+ )}
+
+
+ );
+}
+
+export function C1() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {BACKGROUND.map(([page, items]) => (
+
+ {page}
+ {items}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts
new file mode 100644
index 00000000..04db6fe4
--- /dev/null
+++ b/frontend/src/design-lab/labTokens.ts
@@ -0,0 +1,36 @@
+// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). They are
+// the lab's stand-ins until S3 moves each into index.css / the ui/ primitives; then these go.
+
+/** B1 compact ladder: explicit heights, so one toolbar row lines up at 28. */
+export const H = {
+ md: "h-8 px-3 text-sm gap-1.5",
+ sm: "h-7 px-2.5 text-sm gap-1.5",
+ xs: "h-6 px-2 text-xs gap-1",
+ iconSm: "w-7 h-7",
+ icon: "w-8 h-8",
+ field: "h-8",
+ bar: "h-7",
+} as const;
+
+/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode. */
+export const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
+
+/** B3 state layers: the text colour at 10% (hover) / 16% (pressed) over whatever is underneath;
+ * a filled control mixes its own text colour into the fill at 14% / 24%. */
+export const LAYER =
+ "hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]";
+export const FILLED =
+ "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]";
+export const DANGER =
+ "bg-danger text-danger-fg hover:bg-[color-mix(in_srgb,var(--danger-fg)_14%,var(--danger))] active:bg-[color-mix(in_srgb,var(--danger-fg)_24%,var(--danger))]";
+
+/** B9: the header + fade edge — a crisp band and a 1rem gradient of the colour it sits on. Literal
+ * strings per colour: Tailwind only generates classes it can read whole. */
+export const FADE_BELOW =
+ "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
+export const FADE_BELOW_PANEL =
+ "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
+export const FADE_ABOVE_PANEL =
+ "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
+export const FADE_EDGES_X =
+ "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)";
From f4bc60d6591886586947e9e808331a76ae2a5d62 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 03:27:26 +0200
Subject: [PATCH 005/137] feat(design): batch C recorded, the player row open
as C5 (E28 S2d)
C1-C4 as proposed: the action classification, overlay-drawer side panels,
touch-first players, dialog focus by purpose with a pinned footer. The
players were then played live at tablet size, which found the Plex
control row running off-screen below 1024; how it fits is open as C5.
CLAUDE.md now points at DESIGN.md.
---
CLAUDE.md | 4 +-
docs/DESIGN.md | 94 ++++--
frontend/src/design-lab/DesignLab.tsx | 22 +-
frontend/src/design-lab/batchC.tsx | 367 +++++++++++++---------
frontend/src/design-lab/batchCActions.tsx | 65 ++--
5 files changed, 318 insertions(+), 234 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index c256bd4f..846ffe0f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,7 +46,9 @@ how it gets built. Concretely:
behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
- choose from before building.
+ choose from before building. How the UI looks and behaves is decided in
+ [`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
+ and change it only through its decision log.
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
arguments, push back or propose an alternative — don't comply blindly.
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index b35442d8..218d2ef7 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -4,8 +4,9 @@ This is the one written source of truth for the UI: principles, tokens, the cano
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
code is either a decision still open here (marked **Pending**) or a violation to fix.
-**Status:** being written in E28. Chapters marked _Decided_ are in force. Chapters marked **Pending** are
-open decisions, each rendered side by side in the design lab (below) and settled by the product owner.
+**Status:** written in E28. Chapters marked _Decided_ are in force. One item is still **Pending** (C5, the
+Plex control row on a narrow screen), rendered side by side in the design lab (below) and settled by the
+product owner.
## How this document changes
@@ -58,7 +59,12 @@ style guide.
(A3, under Controls). [HIG, WCAG 2.5.8]
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
when an input gets focus.
-- **Below 768px** (a narrow window, split screen): what replaces the side rails, and how a side panel opens on touch, is **Pending (C2)**. The Plex player on touch is **Pending (C3)**.
+- **Side panels (C2):** on touch, and at any width below 768 (a narrow window, split screen), 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. Pinning is its own button in the panel header, offered
+ only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and
+ click-to-pin. A filter is never unreachable at any supported width. [Nielsen #7, WCAG 1.4.10]
+- **Players on touch (C3):** see [Players](#players).
## Tokens
@@ -159,8 +165,17 @@ Which actions are visible and which live in a menu.
- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
Destructive ones keep their confirmation. [Nielsen #8, #5]
-- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. _Which actions stay
- visible per object is the classification table, **Pending review (C1)**._
+- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu.
+- **The classification (C1)** says, per object, what is visible at rest, on hover and on touch; its full
+ table is the lab's C1 reference, and S3 turns it into each object's one action list in code. In short:
+ - A video or Plex card: play and its state (watched, progress) at rest; the everyday toggles (watched,
+ save, add to playlist) and "…" on hover; on touch the state and a 44px "…" beside the title. Details,
+ download, hide and reset live in the menu.
+ - A row (playlist item, channel, download): its primary action and its values (priority, tags, error)
+ stay visible; destructive and rare row buttons (unsubscribe, delete, cancel, remove) move into the menu.
+ A playlist item's X column goes: Delete key plus an undo toast.
+ - Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive
+ header action ("Clear everything", "Purge discovery") goes into the header's "…".
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
@@ -199,10 +214,27 @@ Wherever there is text, the user can select it and copy it, and no click action
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
-## Dialogs — **Pending (C4)**
+## Players — _Decided_ (C3), one item **Pending (C5)**
-Where focus lands when a dialog opens (by purpose, APG), and whether the footer stays pinned while the body
-scrolls. Open in the lab.
+- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
+ click-to-pause. Measured live: today a tap with the bar hidden both reveals it and pauses. [Nielsen #3]
+- **The hide timer starts with playback,** not with the first touch (today, on touch, the bar stays up until
+ the first tap however long the video plays).
+- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger
+ drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8]
+- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
+ screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
+ live). [WCAG 1.4.13]
+- **The control row on a narrow screen — Pending (C5):** today it runs off the right edge below 1024 (five
+ controls unreachable at 767 and at 820 portrait). Overflow into a "⋯ More" menu vs wrapping onto a second
+ line is open in the lab.
+
+## Dialogs — _Decided_ (C4)
+
+- **Initial focus by purpose** (a `Modal` `initialFocus` rule): a form focuses its first field, a confirm its
+ least destructive button, anything else Close. [WAI-ARIA APG]
+- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at both edges, so the
+ primary action stays on screen when the on-screen keyboard takes half of it. [Nielsen #3, WCAG 2.4.11]
## Copy
@@ -221,24 +253,28 @@ scrolls. Open in the lab.
## Decision log
-| Date | Decision | Chosen | Over | Why |
-| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
-| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
-| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
-| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
-| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
-| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
-| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
-| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
-| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
-| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
-| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
-| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
-| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
-| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
-| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
-| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
-| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
-| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
-| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
+| Date | Decision | Chosen | Over | Why |
+| ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
+| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
+| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
+| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
+| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
+| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
+| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
+| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
+| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
+| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
+| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
+| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
+| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
+| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
+| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
+| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
+| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
+| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
+| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
+| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
+| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
+| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
+| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index d4e756a5..082fe312 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -3,16 +3,10 @@ import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
-import { C1 } from "./batchCActions";
-import { C2, C3, C4 } from "./batchC";
+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: "c1", code: "C1", title: "Action placement", render: C1 },
- { id: "c2", code: "C2", title: "Side panels", render: C2 },
- { id: "c3", code: "C3", title: "Player touch", render: C3 },
- { id: "c4", code: "C4", title: "Dialogs", render: C4 },
-] as const;
+const OPEN = [{ id: "c5", code: "C5", title: "Player row", render: C5 }] as const;
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE , so it owns the theme attributes on itself, through the same data-theme /
@@ -114,12 +108,15 @@ export default function DesignLab() {
B ✓
+
+ C ✓
+
- Batch C · behaviour and device
+ Open · the player row on a narrow screen
Each decision shows its variants with the app's real CSS. The framed one is the
recommendation. Resize the window to tablet size to see them there too.
@@ -142,6 +139,13 @@ export default function DesignLab() {
+
+
);
diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx
index 094ec1cd..5189e732 100644
--- a/frontend/src/design-lab/batchC.tsx
+++ b/frontend/src/design-lab/batchC.tsx
@@ -1,13 +1,34 @@
import clsx from "clsx";
import type { ReactNode } from "react";
-import { Maximize, Pause, Pin, SkipForward, Subtitles, Volume2, X } from "lucide-react";
+import {
+ Activity,
+ Download,
+ FastForward,
+ Info,
+ Keyboard,
+ Maximize,
+ MoreHorizontal,
+ Pause,
+ Pin,
+ Rewind,
+ RotateCcw,
+ Settings,
+ SkipBack,
+ SkipForward,
+ Square,
+ Subtitles,
+ Volume2,
+ X,
+} from "lucide-react";
import { Btn } from "./batchA";
-import { Decision, Sample, Variant } from "./labKit";
+import { C1 } from "./batchCActions";
+import { Decision, Reference, Sample, Variant } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens";
-// E28 S2 · Batch C, part 2 — behaviour on touch and on a narrow window: the side panels, the player,
-// the dialog conventions. These are behaviours, so each variant is a still of the moment that
-// differs, with the sequence written under it.
+// 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, which C3's 44px buttons make worse, so how the row
+// fits is asked on its own.
const ico = "w-4 h-4";
@@ -21,13 +42,10 @@ function Screen({ label, children }: { label: string; children: ReactNode }) {
);
}
-function Content({ cols = 3 }: { cols?: number }) {
+function Content() {
return (
-
- {Array.from({ length: cols * 3 }, (_, i) => (
+
+ {Array.from({ length: 9 }, (_, i) => (
Video {i + 1}
@@ -37,19 +55,15 @@ function Content({ cols = 3 }: { cols?: number }) {
);
}
-function FilterPanel({ pinnable }: { pinnable?: boolean }) {
+function FilterPanel() {
return (
Filters
- {pinnable && (
-
-
-
- )}
+
+
+
@@ -65,106 +79,61 @@ function FilterPanel({ pinnable }: { pinnable?: boolean }) {
}
// ── C2 · side panels on touch and on a narrow window ─────────────────────────────────────────────
-export function C2() {
+function C2() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
);
}
-// ── C3 · the player on touch ─────────────────────────────────────────────────────────────────────
-function Player({ touch }: { touch: boolean }) {
- const btn = touch ? "h-11 w-11" : "h-8 w-8";
+// ── C3 · the players on touch ────────────────────────────────────────────────────────────────────
+function C3() {
+ const btn = "grid h-11 w-11 place-items-center rounded-full";
return (
-
-
- {/* The seek bar: the visible track stays thin; on touch the hit area around it is 44 tall. */}
-
-
- {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
-
-
-
- ))}
- 12:04 / 31:40
-
-
-
-
-
-
- );
-}
-
-export function C3() {
- return (
-
-
-
-
-
-
-
-
+
+
+ {/* The visible track stays thin; the dashed box is the 44-tall hit area around it. */}
+
+
+ {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
+
+
+
+ ))}
+ 12:04 / 31:40
+
+
+
+
+
+
+
);
}
@@ -187,46 +156,34 @@ function Field({ label, value, focused }: { label: string; value: string; focuse
);
}
-function ShortDialog({ pinned }: { pinned: boolean }) {
- const fields = (
-
-
-
-
-
- {!pinned && (
-
-
- Cancel
-
-
- Save
-
-
- )}
-
- );
+function C4() {
return (
-
-
- Edit details
-
+
+
-
-
-
-
{fields}
- {pinned && (
+ Edit details
+
+
+
+
+
Cancel
@@ -235,34 +192,132 @@ function ShortDialog({ pinned }: { pinned: boolean }) {
Save
- )}
+
+
+ );
+}
+
+export function BatchC() {
+ return (
+
);
}
-export function C4() {
+// ── C5 · the Plex control row on a narrow screen ─────────────────────────────────────────────────
+// Every control of today's row, in the order it sits; `keep` = stays in the row under variant A.
+const CONTROLS: { icon: typeof Pause; label: string; keep: boolean }[] = [
+ { icon: Rewind, label: "Back 10 s", keep: true },
+ { icon: Pause, label: "Play / pause", keep: true },
+ { icon: FastForward, label: "Forward 10 s", keep: true },
+ { icon: RotateCcw, label: "Restart from the beginning", keep: false },
+ { icon: Square, label: "Stop", keep: false },
+ { icon: SkipBack, label: "Previous episode", keep: true },
+ { icon: SkipForward, label: "Next episode", keep: true },
+ { icon: Volume2, label: "Mute", keep: true },
+ { icon: Subtitles, label: "Audio & subtitles", keep: true },
+ { icon: Settings, label: "Settings", keep: false },
+ { icon: Activity, label: "Stats for nerds", keep: false },
+ { icon: Info, label: "Media info", keep: false },
+ { icon: Keyboard, label: "Keyboard shortcuts", keep: false },
+ { icon: Download, label: "Download original file", keep: false },
+];
+
+const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full";
+
+function Frame({ children }: { children: ReactNode }) {
+ // Stands in for a portrait tablet: the frame is the screen width, narrower than the full row.
+ return (
+
+ );
+}
+
+function OverflowRow() {
+ return (
+
+
+ {CONTROLS.filter((c) => c.keep).map((c) => (
+
+
+
+ ))}
+ 12:04 / 42:21
+
+
+
+
+
+
+
+
+ {CONTROLS.filter((c) => !c.keep).map((c) => (
+
+
+ {c.label}
+
+ ))}
+
+
+ );
+}
+
+function WrappedRow() {
+ return (
+
+
+ {CONTROLS.map((c) => (
+
+
+
+ ))}
+
+
+
+ 12:04 / 42:21
+
+
+ );
+}
+
+export function C5() {
return (
-
+
-
+
);
diff --git a/frontend/src/design-lab/batchCActions.tsx b/frontend/src/design-lab/batchCActions.tsx
index 7bc9ebbe..1fd014ed 100644
--- a/frontend/src/design-lab/batchCActions.tsx
+++ b/frontend/src/design-lab/batchCActions.tsx
@@ -1,10 +1,11 @@
import clsx from "clsx";
import { Bookmark, Check, ListPlus, MoreHorizontal, Play } from "lucide-react";
-import { Decision, Sample, Variant } from "./labKit";
+import { Reference, Sample } 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
+// lives), DECIDED (owner, 2026-09-24: accepted as proposed). It is the source S3 builds each object's
+// ONE action list from. 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.
@@ -370,47 +371,33 @@ function Card({ touch }: { touch: boolean }) {
export function C1() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {BACKGROUND.map(([page, items]) => (
-
- {page}
- {items}
-
- ))}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {BACKGROUND.map(([page, items]) => (
+
+ {page}
+ {items}
+
+ ))}
+
+
+
-
+
+
);
}
From 3f5634a44f33c9f4c5a1d42c88ef1e8846b517bb Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 04:05:09 +0200
Subject: [PATCH 006/137] feat(design): C5 redrawn around the owner's reworked
Plex controls
Stop, Restart and the 10 s skips leave the row, Back restarts or steps to
the previous episode, Next shows only when there is a next item, and the
card carries Resume + From the start. The lab asks back two points: how
Back tells restart from previous, and a touch path for the 10 s skip.
---
frontend/src/design-lab/DesignLab.tsx | 4 +-
frontend/src/design-lab/batchC.tsx | 194 +++++++++++++++++---------
2 files changed, 128 insertions(+), 70 deletions(-)
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index 082fe312..8420481a 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -6,7 +6,7 @@ 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 row", render: C5 }] as const;
+const OPEN = [{ id: "c5", code: "C5", title: "Player controls", render: C5 }] as const;
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE , so it owns the theme attributes on itself, through the same data-theme /
@@ -116,7 +116,7 @@ export default function DesignLab() {
- Open · the player row on a narrow screen
+ Open · the Plex player controls
Each decision shows its variants with the app's real CSS. The framed one is the
recommendation. Resize the window to tablet size to see them there too.
diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx
index 5189e732..d220d9a1 100644
--- a/frontend/src/design-lab/batchC.tsx
+++ b/frontend/src/design-lab/batchC.tsx
@@ -9,13 +9,13 @@ import {
Maximize,
MoreHorizontal,
Pause,
+ Play,
Pin,
Rewind,
RotateCcw,
Settings,
SkipBack,
SkipForward,
- Square,
Subtitles,
Volume2,
X,
@@ -27,8 +27,8 @@ import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labToken
// 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, which C3's 44px buttons make worse, so how the row
-// fits is asked on its own.
+// control row running off-screen below 1024; the owner answered with a reworked control set, and
+// two of its points are asked back.
const ico = "w-4 h-4";
@@ -210,31 +210,45 @@ export function BatchC() {
);
}
-// ── C5 · the Plex control row on a narrow screen ─────────────────────────────────────────────────
-// Every control of today's row, in the order it sits; `keep` = stays in the row under variant A.
-const CONTROLS: { icon: typeof Pause; label: string; keep: boolean }[] = [
- { icon: Rewind, label: "Back 10 s", keep: true },
- { icon: Pause, label: "Play / pause", keep: true },
- { icon: FastForward, label: "Forward 10 s", keep: true },
- { icon: RotateCcw, label: "Restart from the beginning", keep: false },
- { icon: Square, label: "Stop", keep: false },
- { icon: SkipBack, label: "Previous episode", keep: true },
- { icon: SkipForward, label: "Next episode", keep: true },
- { icon: Volume2, label: "Mute", keep: true },
- { icon: Subtitles, label: "Audio & subtitles", keep: true },
- { icon: Settings, label: "Settings", keep: false },
- { icon: Activity, label: "Stats for nerds", keep: false },
- { icon: Info, label: "Media info", keep: false },
- { icon: Keyboard, label: "Keyboard shortcuts", keep: false },
- { icon: Download, label: "Download original file", keep: false },
+// ── 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.
+
+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 Frame({ children }: { children: ReactNode }) {
- // Stands in for a portrait tablet: the frame is the screen width, narrower than the full row.
+function PlayerRow({ touchZones }: { touchZones: boolean }) {
return (
+ {touchZones && (
+ // Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch.
+
+
+
+ double-tap −10 s
+
+
+
+
+ double-tap +10 s
+
+
+
+ )}
@@ -242,56 +256,99 @@ function Frame({ children }: { children: ReactNode }) {
- {children}
+
+ {ROW.map((c) => (
+
+
+
+ ))}
+ 12:04 / 42:21
+
+
+
+
+
+
+
+
+
+
+
+ {MORE.map((c) => (
+
+
+ {c.label}
+
+ ))}
+
);
}
-function OverflowRow() {
+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 (
-
-
- {CONTROLS.filter((c) => c.keep).map((c) => (
-
-
+
+
+ {state !== "new" && (
+
+
- ))}
- 12:04 / 42:21
-
-
-
-
-
-
-
-
- {CONTROLS.filter((c) => !c.keep).map((c) => (
-
-
- {c.label}
+ )}
+ {state === "new" && (
+
+
+
+
- ))}
+ )}
+ {state === "started" && (
+
+
+ Resume 14:02
+
+
+ From the start
+
+
+ )}
-
+
+
+
Darkman III
+
+ {state === "new" ? "1996 · not started" : "1996 · 14 min in"}
+
+
+ {state === "touch" && (
+
+
+
+ )}
+
+
);
}
-function WrappedRow() {
+function Cards() {
return (
-
-
- {CONTROLS.map((c) => (
-
-
-
- ))}
-
-
-
- 12:04 / 42:21
-
-
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -300,24 +357,25 @@ export function C5() {
-
+
+
-
+
);
From 9c4a999e545e3c69ccf76f1f0629f1471787792a Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 04:12:19 +0200
Subject: [PATCH 007/137] 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.
---
docs/DESIGN.md | 81 +++++++++++++++------------
frontend/src/design-lab/DesignLab.tsx | 23 ++------
frontend/src/design-lab/batchC.tsx | 74 ++++++++++--------------
frontend/src/design-lab/labKit.tsx | 81 ++-------------------------
4 files changed, 85 insertions(+), 174 deletions(-)
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 218d2ef7..90ee3714 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -4,9 +4,9 @@ This is the one written source of truth for the UI: principles, tokens, the cano
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
code is either a decision still open here (marked **Pending**) or a violation to fix.
-**Status:** written in E28. Chapters marked _Decided_ are in force. One item is still **Pending** (C5, the
-Plex control row on a narrow screen), rendered side by side in the design lab (below) and settled by the
-product owner.
+**Status:** written in E28; every chapter is _Decided_ and in force. The design lab (below) renders each rule's
+chosen form. A new question opens a **Pending** item here, with its variants in the lab, until the product
+owner settles it.
## How this document changes
@@ -214,20 +214,30 @@ Wherever there is text, the user can select it and copy it, and no click action
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
-## Players — _Decided_ (C3), one item **Pending (C5)**
+## Players — _Decided_ (C3, C5)
- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
- click-to-pause. Measured live: today a tap with the bar hidden both reveals it and pauses. [Nielsen #3]
-- **The hide timer starts with playback,** not with the first touch (today, on touch, the bar stays up until
- the first tap however long the video plays).
+ click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3]
+- **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until
+ the first tap however long the video played).
- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger
drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8]
+- **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet.
+ - No Stop (closing the player is stopping), no Restart, no ±10 s buttons.
+ - **Back:** more than 3 s in → back to the start; within the first 3 s → the previous item, at its resume
+ point if it has one, else its start. The first item has only the restart.
+ - **Next** shows only when there is a next item (an episode, a playlist queue). It starts that item at its
+ resume point and leaves one behind on the current item, which does not count as watched unless it is
+ already inside the finish margin (the credits).
+ - **±10 s:** the keyboard keeps ← / →; on touch a double-tap on the left or right third of the picture skips
+ ∓10 s, while a single tap still only shows the controls. Touch never loses a function. [WCAG 2.5.1]
+ - The row: play/pause, Back, Next, mute, the time, audio & subtitles, a fixed **⋯** (player settings, media
+ info, stats, keyboard shortcuts, download the original file), fullscreen.
+- **Restarting lives on the media card** (C5): not started → Play; started → **Resume** (primary) + **From
+ the start**. On touch a tap resumes and the card's "…" menu has From the start.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13]
-- **The control row on a narrow screen — Pending (C5):** today it runs off the right edge below 1024 (five
- controls unreachable at 767 and at 820 portrait). Overflow into a "⋯ More" menu vs wrapping onto a second
- line is open in the lab.
## Dialogs — _Decided_ (C4)
@@ -253,28 +263,29 @@ Wherever there is text, the user can select it and copy it, and no click action
## Decision log
-| Date | Decision | Chosen | Over | Why |
-| ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
-| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
-| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
-| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
-| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
-| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
-| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
-| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
-| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
-| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
-| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
-| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
-| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
-| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
-| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
-| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
-| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
-| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
-| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
-| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
-| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
-| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
-| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
+| Date | Decision | Chosen | Over | Why |
+| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
+| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
+| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
+| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
+| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
+| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
+| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
+| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
+| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
+| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
+| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
+| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
+| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
+| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
+| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
+| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
+| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
+| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
+| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
+| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
+| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
+| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
+| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
+| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index 8420481a..c28b1186 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -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 , so it owns the theme attributes on itself, through the same data-theme /
@@ -93,15 +90,6 @@ export default function DesignLab() {
/>
- {OPEN.map((d) => (
-
- {d.code} {d.title}
-
- ))}
A ✓
@@ -116,15 +104,12 @@ export default function DesignLab() {
- Open · the Plex player controls
+ The design reference
- Each decision shows its variants with the app'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's real CSS.
+ Resize the window to tablet size to see them there too.
- {OPEN.map((d) => (
-
- ))}
Decided · batch A, controls
diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx
index d220d9a1..6e0cd836 100644
--- a/frontend/src/design-lab/batchC.tsx
+++ b/frontend/src/design-lab/batchC.tsx
@@ -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() {
+
);
}
-// ── 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 (
- {touchZones && (
- // Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch.
-
-
-
- double-tap −10 s
-
+ {/* Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch. */}
+
+
+
+ double-tap −10 s
-
-
- double-tap +10 s
-
+
+
+
+ double-tap +10 s
-
- )}
+
+
@@ -352,31 +349,20 @@ function Cards() {
);
}
-export function C5() {
+function C5() {
return (
-
-
-
+
+
);
}
diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx
index 4b08758a..050b282c 100644
--- a/frontend/src/design-lab/labKit.tsx
+++ b/frontend/src/design-lab/labKit.tsx
@@ -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 : two or
-// three 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 (
-
-
-
- {code}
-
{title}
-
- {question}
-
- Today:
- {now}
-
-
- {children}
-
- );
-}
-
-export function Variant({
- letter,
- name,
- recommended = false,
- why,
- children,
-}: {
- letter: string;
- name: string;
- recommended?: boolean;
- why: string;
- children: ReactNode;
-}) {
- return (
-
-
-
- {letter}
-
-
{name}
- {recommended && (
-
- Recommended
-
- )}
-
- {children}
- {why}
-
- );
-}
+// Building blocks of the design lab. Every E28 decision is taken, so the lab is the reference half
+// only: each is the chosen rendering with the rule it became. A new design round brings
+// back the side-by-side / 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,
From ad136c3fe8fc64883b16af8026e2e7c8fa7cafee Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 04:33:13 +0200
Subject: [PATCH 008/137] feat(design): the scheme set, the colour contract and
the token utilities (E28 S3a)
Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube
and Starship retired with no data migration: normalizeTheme maps a stored
retired scheme to Midnight. A vitest contract measures every scheme x mode
(text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap);
it caught light-mode danger at 4.43:1, now red-700. The state layers, the
strong edge, the touch target and the fades are index.css tokens and
utilities the lab now reads. Backdrops recoloured to the new accents, with
a grey overlay grain that actually breaks the banding (longest flat run
15-26px -> 4-8px). The glass tuner is retired.
---
docs/DESIGN.md | 13 +
frontend/public/backdrops/forest.svg | 8 +-
frontend/public/backdrops/light/forest.svg | 8 +-
frontend/public/backdrops/light/matrix.svg | 10 +-
frontend/public/backdrops/light/midnight.svg | 4 +-
frontend/public/backdrops/light/slate.svg | 14 +-
frontend/public/backdrops/light/starship.svg | 18 -
frontend/public/backdrops/light/youtube.svg | 18 -
frontend/public/backdrops/matrix.svg | 10 +-
frontend/public/backdrops/midnight.svg | 4 +-
frontend/public/backdrops/slate.svg | 14 +-
frontend/public/backdrops/starship.svg | 18 -
frontend/public/backdrops/youtube.svg | 18 -
frontend/src/App.tsx | 3 -
frontend/src/components/GlassTuner.tsx | 465 ------------------
frontend/src/components/PrefsProvider.tsx | 6 +-
frontend/src/components/SettingsPanel.tsx | 10 -
frontend/src/design-lab/batchB.tsx | 22 +-
frontend/src/design-lab/labTokens.ts | 35 +-
frontend/src/i18n/locales/en/settings.json | 57 ---
frontend/src/i18n/locales/hu/settings.json | 57 ---
frontend/src/index.css | 205 ++++----
.../labColor.ts => lib/colorMath.ts} | 18 +-
frontend/src/lib/theme.contract.test.ts | 76 +++
frontend/src/lib/theme.test.ts | 30 ++
frontend/src/lib/theme.ts | 36 +-
26 files changed, 326 insertions(+), 851 deletions(-)
delete mode 100644 frontend/public/backdrops/light/starship.svg
delete mode 100644 frontend/public/backdrops/light/youtube.svg
delete mode 100644 frontend/public/backdrops/starship.svg
delete mode 100644 frontend/public/backdrops/youtube.svg
delete mode 100644 frontend/src/components/GlassTuner.tsx
rename frontend/src/{design-lab/labColor.ts => lib/colorMath.ts} (61%)
create mode 100644 frontend/src/lib/theme.contract.test.ts
create mode 100644 frontend/src/lib/theme.test.ts
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 90ee3714..17c48a41 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -77,10 +77,22 @@ style guide.
`--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on
the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3]
+- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css`
+ against the two rules above (text 4.5:1, a control edge 3:1, the status colours as text, the accent's hue
+ gap). A scheme or a retuned colour that breaks them does not pass the gate.
+- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was
+ retired (E28), because free tuning is exactly the drift these rules exist to stop.
+- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a
+ grey `overlay` grain that breaks the gradient banding (measured by pixel-scanning a canvas, never a
+ screenshot, which rescales).
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent.
+- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`,
+ `--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`,
+ `edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab
+ rendering uses them; restating one as an arbitrary class is a violation.
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
px font size or a px-frozen box is an eslint error.
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
@@ -289,3 +301,4 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
+| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
diff --git a/frontend/public/backdrops/forest.svg b/frontend/public/backdrops/forest.svg
index 5e8f0f44..fc664188 100644
--- a/frontend/public/backdrops/forest.svg
+++ b/frontend/public/backdrops/forest.svg
@@ -1,11 +1,11 @@
-
-
+
+
-
+
@@ -14,5 +14,5 @@
-
+
diff --git a/frontend/public/backdrops/light/forest.svg b/frontend/public/backdrops/light/forest.svg
index 9ecade12..205a6cbe 100644
--- a/frontend/public/backdrops/light/forest.svg
+++ b/frontend/public/backdrops/light/forest.svg
@@ -1,11 +1,11 @@
-
-
+
+
-
+
@@ -14,5 +14,5 @@
-
+
diff --git a/frontend/public/backdrops/light/matrix.svg b/frontend/public/backdrops/light/matrix.svg
index b679c1b9..9ef8303c 100644
--- a/frontend/public/backdrops/light/matrix.svg
+++ b/frontend/public/backdrops/light/matrix.svg
@@ -1,11 +1,11 @@
-
-
-
+
+
+
-
+
@@ -14,5 +14,5 @@
-
+
diff --git a/frontend/public/backdrops/light/midnight.svg b/frontend/public/backdrops/light/midnight.svg
index 37dd1b7b..15cf1c8a 100644
--- a/frontend/public/backdrops/light/midnight.svg
+++ b/frontend/public/backdrops/light/midnight.svg
@@ -5,7 +5,7 @@
-
+
@@ -14,5 +14,5 @@
-
+
diff --git a/frontend/public/backdrops/light/slate.svg b/frontend/public/backdrops/light/slate.svg
index 83354ab3..c8027b7d 100644
--- a/frontend/public/backdrops/light/slate.svg
+++ b/frontend/public/backdrops/light/slate.svg
@@ -1,18 +1,18 @@
-
-
-
-
+
+
+
+
-
+
-
+
-
+
diff --git a/frontend/public/backdrops/light/starship.svg b/frontend/public/backdrops/light/starship.svg
deleted file mode 100644
index 9ba4aeb9..00000000
--- a/frontend/public/backdrops/light/starship.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/public/backdrops/light/youtube.svg b/frontend/public/backdrops/light/youtube.svg
deleted file mode 100644
index 16cb1ffa..00000000
--- a/frontend/public/backdrops/light/youtube.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg
index 3e699a7b..038ecfc8 100644
--- a/frontend/public/backdrops/matrix.svg
+++ b/frontend/public/backdrops/matrix.svg
@@ -1,11 +1,11 @@
-
-
-
+
+
+
-
+
@@ -14,5 +14,5 @@
-
+
diff --git a/frontend/public/backdrops/midnight.svg b/frontend/public/backdrops/midnight.svg
index 217531ff..f51b6564 100644
--- a/frontend/public/backdrops/midnight.svg
+++ b/frontend/public/backdrops/midnight.svg
@@ -5,7 +5,7 @@
-
+
@@ -14,5 +14,5 @@
-
+
diff --git a/frontend/public/backdrops/slate.svg b/frontend/public/backdrops/slate.svg
index b5c4889c..bb77777f 100644
--- a/frontend/public/backdrops/slate.svg
+++ b/frontend/public/backdrops/slate.svg
@@ -1,18 +1,18 @@
-
-
-
-
+
+
+
+
-
+
-
+
-
+
diff --git a/frontend/public/backdrops/starship.svg b/frontend/public/backdrops/starship.svg
deleted file mode 100644
index 2a8d704d..00000000
--- a/frontend/public/backdrops/starship.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/public/backdrops/youtube.svg b/frontend/public/backdrops/youtube.svg
deleted file mode 100644
index 6d50059a..00000000
--- a/frontend/public/backdrops/youtube.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 38104105..6a09d077 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -32,7 +32,6 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell";
-import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import DebugRecorder from "./components/DebugRecorder";
import Toaster from "./components/ui/Toaster";
@@ -475,8 +474,6 @@ export default function App() {
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
- {/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
-
);
}
diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx
deleted file mode 100644
index f1c3a679..00000000
--- a/frontend/src/components/GlassTuner.tsx
+++ /dev/null
@@ -1,465 +0,0 @@
-import { useEffect, useId, useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
-import Overlay from "./ui/Overlay";
-import { copyText } from "../lib/clipboard";
-import { usePrefs } from "./prefsContext";
-
-/**
- * A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
- * writes the glass CSS custom properties (index.css `:root`) inline on the document element for
- * instant, build-free feedback, and persists to localStorage.
- *
- * Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy"
- * for dialling in the surface values. "Copy CSS" exports the values.
- */
-const LS_KEY = "siftlode.devGlassTuner";
-
-type Slider = {
- k: string;
- /** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */
- id: string;
- min: number;
- max: number;
- step: number;
- unit: string;
- def: number;
-};
-
-// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it.
-// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css.
-const SLIDERS: Slider[] = [
- { k: "--glass-blur", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 },
- { k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
- {
- k: "--glass-dark-bright",
- id: "darkBright",
- min: 1,
- max: 2,
- step: 0.05,
- unit: "",
- def: 1.2,
- },
- {
- k: "--glass-surface-alpha",
- id: "surfaceAlpha",
- min: 40,
- max: 100,
- step: 1,
- unit: "%",
- def: 50,
- },
- {
- k: "--glass-card-alpha",
- id: "cardAlpha",
- min: 40,
- max: 100,
- step: 1,
- unit: "%",
- def: 60,
- },
- {
- k: "--glass-menu-alpha",
- id: "menuAlpha",
- min: 40,
- max: 100,
- step: 1,
- unit: "%",
- def: 66,
- },
- {
- k: "--glass-border-alpha",
- id: "borderAlpha",
- min: 20,
- max: 100,
- step: 1,
- unit: "%",
- def: 80,
- },
- { k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 },
- { k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 },
- { k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
- { k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 },
-];
-
-const PALETTE: { k: string; id: string }[] = [
- { k: "--bg", id: "bg" },
- { k: "--surface", id: "surface" },
- { k: "--card", id: "card" },
- { k: "--border", id: "border" },
- { k: "--fg", id: "fg" },
- { k: "--muted", id: "muted" },
- { k: "--accent", id: "accent" },
-];
-
-// Each preset resets every slider to its default, then applies these overrides.
-// Named under `settings.tuner.preset.
`.
-const PRESETS: { id: string; over: Record }[] = [
- { id: "current", over: {} },
- {
- id: "solid",
- over: {
- "--glass-blur": 20,
- "--glass-dark-bright": 1.25,
- "--glass-surface-alpha": 94,
- "--glass-card-alpha": 94,
- "--glass-menu-alpha": 98,
- "--glass-edge": 10,
- "--glass-inset": 16,
- "--glass-scrim": 14,
- },
- },
- {
- id: "readable",
- over: {
- "--glass-edge": 42,
- "--glass-scrim": 45,
- "--glass-surface-alpha": 96,
- "--glass-card-alpha": 96,
- },
- },
-];
-
-const ALL = SLIDERS;
-const root = () => document.documentElement;
-const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
-
-type Saved = {
- vars?: Record;
- palette?: Record;
- open?: boolean;
-};
-
-function loadSaved(): Saved {
- try {
- return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved;
- } catch {
- return {};
- }
-}
-
-export default function GlassTuner() {
- // Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
- const enabled = usePrefs().theme.showTuner;
- const { t } = useTranslation();
- // Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list:
- // a memo is a caching hint React may discard and recompute, while "the saved values as they were
- // when this mounted" is state — and the effect below applies them exactly once.
- const [initial] = useState(loadSaved);
- const [vars, setVars] = useState>(() => {
- const base: Record = {};
- ALL.forEach((s) => (base[s.k] = s.def));
- return { ...base, ...(initial.vars || {}) };
- });
- const [palette, setPalette] = useState>(() => initial.palette || {});
- const [open, setOpen] = useState(() => initial.open ?? true);
- const [tab, setTab] = useState<"glass" | "palette">("glass");
- const [copied, setCopied] = useState(false);
- const [themeTick, setThemeTick] = useState(0);
- // Seed the colour pickers from the live scheme for display (without forcing them onto ).
- const [swatches, setSwatches] = useState>({});
-
- // Apply saved var overrides (and any saved palette edits) once on mount.
- useEffect(() => {
- Object.entries(initial.vars || {}).forEach(([k, v]) => {
- const unit = ALL.find((s) => s.k === k)?.unit ?? "";
- root().style.setProperty(k, v + unit);
- });
- Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v));
- const sw: Record = {};
- PALETTE.forEach((p) => {
- const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
- sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
- });
- // eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit.
- setSwatches(sw);
- }, [initial]);
-
- function persist(next: Partial) {
- const cur = loadSaved();
- localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next }));
- }
-
- function setVar(k: string, value: number) {
- const unit = ALL.find((s) => s.k === k)?.unit ?? "";
- root().style.setProperty(k, value + unit);
- setVars((prev) => {
- const next = { ...prev, [k]: value };
- persist({ vars: next });
- return next;
- });
- }
-
- function setColor(k: string, hex: string) {
- root().style.setProperty(k, hex);
- setSwatches((s) => ({ ...s, [k]: hex }));
- setPalette((prev) => {
- const next = { ...prev, [k]: hex };
- persist({ palette: next });
- return next;
- });
- }
-
- function applyPreset(over: Record) {
- const next: Record = { ...vars };
- SLIDERS.forEach((s) => {
- const v = over[s.k] ?? s.def;
- next[s.k] = v;
- root().style.setProperty(s.k, v + s.unit);
- });
- setVars(next);
- persist({ vars: next });
- }
-
- function reset() {
- [...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k));
- const base: Record = {};
- ALL.forEach((s) => (base[s.k] = s.def));
- setVars(base);
- setPalette({});
- localStorage.removeItem(LS_KEY);
- const sw: Record = {};
- PALETTE.forEach((p) => {
- const v = readVar(p.k);
- sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
- });
- setSwatches(sw);
- }
-
- // Re-read the LIVE, theme-driven glass values into the sliders (clearing inline overrides first),
- // so the panel reflects whatever theme is active — switch to light, hit this, and the opacity
- // sliders show the light-theme values (index.css `html[data-theme="light"]`) ready to tune.
- function syncToTheme() {
- ALL.forEach((s) => root().style.removeProperty(s.k));
- const next: Record = { ...vars };
- ALL.forEach((s) => {
- const n = parseFloat(readVar(s.k));
- if (!Number.isNaN(n)) next[s.k] = n;
- });
- setVars(next);
- persist({ vars: {} });
- setThemeTick((t) => t + 1);
- }
-
- // Export the values that differ from the baseline, headered for the active theme's scope so a
- // light-theme tune pastes into the right block.
- const cssOut = useMemo(() => {
- void themeTick; // re-run when the active theme is re-synced
- const lines: string[] = [];
- ALL.forEach((s) => {
- if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`);
- });
- PALETTE.forEach((p) => {
- if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`);
- });
- const scope = root().dataset.theme === "light" ? 'html[data-theme="light"]' : ":root";
- return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */";
- }, [vars, palette, themeTick]);
-
- async function copy() {
- if (await copyText(cssOut)) {
- setCopied(true);
- setTimeout(() => setCopied(false), 1200);
- }
- }
-
- function persistOpen(v: boolean) {
- setOpen(v);
- persist({ open: v });
- }
-
- if (!enabled) return null;
-
- return (
-
-
- {!open && (
-
persistOpen(true)}
- style={{ pointerEvents: "auto" }}
- className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
- title={t("settings.tuner.open")}
- >
- {t("settings.tuner.tab")}
-
- )}
- {open && (
-
-
- {t("settings.tuner.title")}
-
- {t("settings.tuner.lab")}
-
- persistOpen(false)}
- className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
- title={t("settings.tuner.collapse")}
- aria-label={t("settings.tuner.collapse")}
- >
- ×
-
-
-
-
- {/* Treatment presets */}
-
-
- {t("settings.tuner.treatment")}
-
-
- {PRESETS.map((p) => (
- applyPreset(p.over)}
- title={t(`settings.tuner.preset.${p.id}.hint`)}
- className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
- >
-
- {t(`settings.tuner.preset.${p.id}.name`)}
-
-
- {t(`settings.tuner.preset.${p.id}.hint`)}
-
-
- ))}
-
-
-
- {/* Tab switch */}
-
- {(["glass", "palette"] as const).map((id) => (
- setTab(id)}
- aria-pressed={tab === id}
- className={`flex-1 rounded-lg px-2 py-1 text-xs ${
- tab === id
- ? "bg-accent text-(--accent-fg) font-semibold"
- : "glass-card text-muted"
- }`}
- >
- {t(`settings.tuner.tabs.${id}`)}
-
- ))}
-
-
- {tab === "glass" && (
-
- {SLIDERS.map((s) => (
- setVar(s.k, v)}
- />
- ))}
-
- )}
-
- {tab === "palette" && (
-
-
- {t("settings.tuner.paletteHint")}
-
- {PALETTE.map((p) => (
-
- setColor(p.k, e.target.value)}
- className="w-8 h-6 rounded-sm border border-border bg-transparent cursor-pointer"
- />
- {t(`settings.tuner.color.${p.id}`)}
- {swatches[p.k]}
-
- ))}
-
- )}
-
- {/* Export */}
-
-
- {t("settings.tuner.export")}
-
-
- {t("settings.tuner.sync")}
-
-
-
- {copied ? t("settings.tuner.copied") : t("settings.tuner.copy")}
-
-
- {t("settings.tuner.reset")}
-
-
-
- {cssOut}
-
-
-
-
- )}
-
-
- );
-}
-
-function Ctl({
- s,
- label,
- value,
- onChange,
-}: {
- s: Slider;
- label: string;
- value: number;
- onChange: (v: number) => void;
-}) {
- // The label names the number box, and the range beside it says the same thing: both controls
- // are one setting, and the label was not tied to either before (a screen reader read "spin
- // button" and "slider" with no name at all).
- const id = useId();
- return (
-
-
- {label}
-
- onChange(Number(e.target.value))}
- className="w-16 text-right text-2xs tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent"
- />
- onChange(Number(e.target.value))}
- aria-label={label}
- className="col-span-2 w-full accent-accent"
- />
-
- );
-}
diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx
index 7a0f655e..3d2878bd 100644
--- a/frontend/src/components/PrefsProvider.tsx
+++ b/frontend/src/components/PrefsProvider.tsx
@@ -12,8 +12,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { api, type Me } from "../lib/api";
import {
applyTheme,
- DEFAULT_THEME,
loadLocalTheme,
+ normalizeTheme,
saveLocalTheme,
type ThemePrefs,
} from "../lib/theme";
@@ -28,7 +28,7 @@ const PERF_KEY = LS.perfMode;
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
-// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs() (prefsContext.ts).
+// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
@@ -44,7 +44,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
const [seed] = useState(() => {
const prefs = qc.getQueryData(qk.me())?.preferences;
return {
- theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
+ theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
performanceMode:
typeof prefs?.performanceMode === "boolean"
? prefs.performanceMode
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 6da08fbd..c69407f3 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -213,16 +213,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
>
-
- setTheme({ ...theme, showTuner: v })}
- />
-
diff --git a/frontend/src/design-lab/batchB.tsx b/frontend/src/design-lab/batchB.tsx
index 97700769..b3418d02 100644
--- a/frontend/src/design-lab/batchB.tsx
+++ b/frontend/src/design-lab/batchB.tsx
@@ -21,17 +21,9 @@ import {
Youtube,
} from "lucide-react";
import { Btn } from "./batchA";
-import { hexContrast, hueGap } from "./labColor";
+import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
-import {
- FADE_ABOVE_PANEL,
- FADE_BELOW,
- FADE_BELOW_PANEL,
- FADE_EDGES_X,
- FILLED,
- H,
- LAYER,
-} from "./labTokens";
+import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
@@ -57,7 +49,7 @@ type SchemeDef = { name: string; dark: Pal; light: Pal };
const STATUS: Record> = {
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
- light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
+ light: { danger: "#b91c1c", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
};
const pal = (
@@ -70,7 +62,8 @@ const pal = (
accentFg: string,
): Pal => ({ bg, surface, border, fg, muted, accent, accentFg });
-// Backgrounds and text are today's (index.css); only the accents are new.
+// A literal copy of index.css's scheme tokens, so the four tiles render side by side (the live CSS
+// switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it.
const SCHEMES: SchemeDef[] = [
{
name: "Midnight · blue",
@@ -458,10 +451,7 @@ function Fades() {
-
+
{CHIPS.map((c) => (
diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts
index 04db6fe4..6f22f927 100644
--- a/frontend/src/design-lab/labTokens.ts
+++ b/frontend/src/design-lab/labTokens.ts
@@ -1,5 +1,6 @@
-// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). They are
-// the lab's stand-ins until S3 moves each into index.css / the ui/ primitives; then these go.
+// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). The colour
+// rules are real now (E28 S3a): each constant below names the index.css utility or token that holds
+// it, so the lab renders what the app ships. The height ladder moves into ui/Button in S3b.
/** B1 compact ladder: explicit heights, so one toolbar row lines up at 28. */
export const H = {
@@ -12,25 +13,15 @@ export const H = {
bar: "h-7",
} as const;
-/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode. */
-export const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
+/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */
+export const STRONG = "var(--edge-strong)";
-/** B3 state layers: the text colour at 10% (hover) / 16% (pressed) over whatever is underneath;
- * a filled control mixes its own text colour into the fill at 14% / 24%. */
-export const LAYER =
- "hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]";
-export const FILLED =
- "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]";
-export const DANGER =
- "bg-danger text-danger-fg hover:bg-[color-mix(in_srgb,var(--danger-fg)_14%,var(--danger))] active:bg-[color-mix(in_srgb,var(--danger-fg)_24%,var(--danger))]";
+/** B3 state layers (index.css utilities). */
+export const LAYER = "state-layer";
+export const FILLED = "state-filled";
+export const DANGER = "state-danger";
-/** B9: the header + fade edge — a crisp band and a 1rem gradient of the colour it sits on. Literal
- * strings per colour: Tailwind only generates classes it can read whole. */
-export const FADE_BELOW =
- "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
-export const FADE_BELOW_PANEL =
- "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
-export const FADE_ABOVE_PANEL =
- "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
-export const FADE_EDGES_X =
- "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)";
+/** B9: the header + fade edge (index.css utilities; the host is positioned). */
+export const FADE_BELOW = "fade-below";
+export const FADE_BELOW_PANEL = "fade-below-panel";
+export const FADE_ABOVE_PANEL = "fade-above-panel";
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index 197099d5..dd1b4883 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -33,8 +33,6 @@
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints",
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
- "showTuner": "Show the glass tuner",
- "showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
"startup": "Startup",
"startOnHome": "Start on Home",
"startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
@@ -249,60 +247,5 @@
"clear": "Clear",
"empty": "No event matches the filter.",
"emptyAll": "No events yet."
- },
- "tuner": {
- "open": "Open the glass tuner",
- "tab": "◐ GLASS",
- "title": "◐ Glass Tuner",
- "lab": "lab",
- "collapse": "Collapse",
- "treatment": "Dark treatment",
- "tabs": {
- "glass": "Glass",
- "palette": "Palette"
- },
- "paletteHint": "Overrides the live scheme (inline on ). Reset clears these back to the scheme.",
- "export": "Export → bake in",
- "syncHint": "Load the values of the theme you're currently viewing (light or dark) into the sliders",
- "sync": "⟳ Sync to current theme",
- "copy": "Copy CSS",
- "copied": "Copied ✓",
- "reset": "Reset",
- "slider": {
- "blur": "Blur radius",
- "saturate": "Saturation",
- "darkBright": "Dark brightness",
- "surfaceAlpha": "Panel opacity",
- "cardAlpha": "Card opacity",
- "menuAlpha": "Menu opacity",
- "borderAlpha": "Border opacity",
- "edge": "Edge-light rim",
- "inset": "Top highlight",
- "scrim": "Under-text scrim",
- "ambient": "Ambient intensity"
- },
- "color": {
- "bg": "Background",
- "surface": "Surface",
- "card": "Card",
- "border": "Border",
- "fg": "Text",
- "muted": "Muted",
- "accent": "Accent"
- },
- "preset": {
- "current": {
- "name": "Glass/image",
- "hint": "The default (over image)"
- },
- "solid": {
- "name": "Solid (flat)",
- "hint": "As shown w/ image off"
- },
- "readable": {
- "name": "Max readable",
- "hint": "Solid + strong scrim"
- }
- }
}
}
diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json
index 3456df8d..1d27c829 100644
--- a/frontend/src/i18n/locales/hu/settings.json
+++ b/frontend/src/i18n/locales/hu/settings.json
@@ -33,8 +33,6 @@
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése",
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
- "showTuner": "Glass tuner megjelenítése",
- "showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.",
"startup": "Indulás",
"startOnHome": "Indítás a kezdőlapon",
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
@@ -249,60 +247,5 @@
"clear": "Ürítés",
"empty": "Nincs a szűrésnek megfelelő esemény.",
"emptyAll": "Még nincs esemény."
- },
- "tuner": {
- "open": "Glass tuner megnyitása",
- "tab": "◐ ÜVEG",
- "title": "◐ Glass tuner",
- "lab": "labor",
- "collapse": "Összecsukás",
- "treatment": "Sötét téma",
- "tabs": {
- "glass": "Üveg",
- "palette": "Paletta"
- },
- "paletteHint": "Felülírja az élő színsémát (inline a -en). A Visszaállítás a séma színeit adja vissza.",
- "export": "Exportálás → beégetés",
- "syncHint": "A most látott téma (világos vagy sötét) értékeinek betöltése a csúszkákba",
- "sync": "⟳ Igazítás a jelenlegi témához",
- "copy": "CSS másolása",
- "copied": "Másolva ✓",
- "reset": "Visszaállítás",
- "slider": {
- "blur": "Elmosás sugara",
- "saturate": "Telítettség",
- "darkBright": "Sötét fényerő",
- "surfaceAlpha": "Panel fedése",
- "cardAlpha": "Kártya fedése",
- "menuAlpha": "Menü fedése",
- "borderAlpha": "Keret fedése",
- "edge": "Élfény-perem",
- "inset": "Felső csúcsfény",
- "scrim": "Szöveg alatti sötétítés",
- "ambient": "Környezeti intenzitás"
- },
- "color": {
- "bg": "Háttér",
- "surface": "Felület",
- "card": "Kártya",
- "border": "Keret",
- "fg": "Szöveg",
- "muted": "Halvány",
- "accent": "Kiemelés"
- },
- "preset": {
- "current": {
- "name": "Üveg/kép",
- "hint": "Az alapérték (kép fölött)"
- },
- "solid": {
- "name": "Tömör (lapos)",
- "hint": "Ahogy kép nélkül látszik"
- },
- "readable": {
- "name": "Legolvashatóbb",
- "hint": "Tömör + erős sötétítés"
- }
- }
}
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 947f6e0b..a2d21f3a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -46,7 +46,6 @@
--z-index-overlay: 50;
--z-index-popover: 60;
--z-index-tooltip: 100;
- --z-index-tuner: 9999;
}
/* Extra-small text steps in rem, so these labels scale with --font-scale like every other
@@ -62,6 +61,90 @@
--text-3xs: 0.625rem; /* 10px @ base */
}
+/* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design
+ lab both read, so a decided value is never restated as an arbitrary class. ===== */
+
+/* B3: a transparent control's hover / pressed layer. */
+@utility state-layer {
+ &:hover {
+ background-color: var(--hover);
+ }
+ &:active {
+ background-color: var(--press);
+ }
+}
+/* B3: a filled control mixes its own text colour into its fill (14% hover, 24% pressed). */
+@utility state-filled {
+ background-color: var(--accent);
+ color: var(--accent-fg);
+ &:hover {
+ background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent));
+ }
+ &:active {
+ background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent));
+ }
+}
+@utility state-danger {
+ background-color: var(--danger);
+ color: var(--danger-fg);
+ &:hover {
+ background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger));
+ }
+ &:active {
+ background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger));
+ }
+}
+/* A4: a control's ≥ 3:1 edge. */
+@utility edge-strong {
+ border-color: var(--edge-strong);
+}
+
+/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
+ dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
+@utility fade-below {
+ &::after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ inset-inline: 0;
+ top: 100%;
+ height: 1rem;
+ background: linear-gradient(to bottom, var(--bg), transparent);
+ }
+}
+@utility fade-below-panel {
+ &::after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ inset-inline: 0;
+ top: 100%;
+ height: 1rem;
+ background: linear-gradient(to bottom, var(--surface), transparent);
+ }
+}
+@utility fade-above-panel {
+ &::before {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ inset-inline: 0;
+ bottom: 100%;
+ height: 1rem;
+ background: linear-gradient(to top, var(--surface), transparent);
+ }
+}
+/* B9: the clipped ends of an overflowing chip row. */
+@utility fade-edges-x {
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 1.75rem,
+ #000 calc(100% - 1.75rem),
+ transparent
+ );
+}
+
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -225,22 +308,6 @@ html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body {
),
url("/backdrops/slate.svg");
}
-html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/youtube.svg");
-}
-html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/starship.svg");
-}
html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body {
background-image:
linear-gradient(
@@ -273,22 +340,6 @@ html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body {
),
url("/backdrops/light/slate.svg");
}
-html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/light/youtube.svg");
-}
-html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body {
- background-image:
- linear-gradient(
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
- color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
- ),
- url("/backdrops/light/starship.svg");
-}
html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body {
background-image:
linear-gradient(
@@ -320,11 +371,17 @@ html {
jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */
font-size: round(calc(16px * var(--font-scale)), 1px);
background: var(--bg);
- /* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`),
- so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens
- on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every
- hoverable row (the .selectable recipe + DataTable rows). */
- --hover: color-mix(in srgb, var(--fg) 9%, transparent);
+ /* State layers (docs/DESIGN.md B3): hover and pressed are the text colour laid over whatever the
+ control sits on — the page, a card or glass — so they read in every scheme and theme (it lightens
+ on dark, darkens on light). Never an opacity dim or a bg-card swap (invisible on a light card).
+ Applied through the state-layer / state-filled / state-danger utilities below. */
+ --hover: color-mix(in srgb, var(--fg) 10%, transparent);
+ --press: color-mix(in srgb, var(--fg) 16%, transparent);
+ /* A control's edge (DESIGN.md A4): ≥ 3:1 against the page in every scheme and mode, where --border
+ (a decorative divider) is not. theme.contract.test.ts holds the ratio. */
+ --edge-strong: color-mix(in srgb, var(--fg) 55%, var(--bg));
+ /* The coarse-pointer target (DESIGN.md A3): one token, read by .touch-target and the primitives. */
+ --touch-target: 2.75rem;
}
body {
@@ -598,8 +655,8 @@ body {
would be the one control that ignores the user's text-size setting. */
@media (pointer: coarse) {
.touch-target {
- min-width: 2.75rem;
- min-height: 2.75rem;
+ min-width: var(--touch-target);
+ min-height: var(--touch-target);
}
}
@@ -858,7 +915,7 @@ html[data-scheme="midnight"][data-theme="light"] {
--accent-fg: #ffffff;
}
-/* Forest — dark slate with teal/green accent */
+/* Forest — dark green-slate with a cyan accent (E28 B2: teal read as success) */
html[data-scheme="forest"][data-theme="dark"] {
--bg: #0a1512;
--surface: #0f1f1a;
@@ -866,7 +923,7 @@ html[data-scheme="forest"][data-theme="dark"] {
--border: #1f3a30;
--fg: #e6f0ec;
--muted: #90b1a4;
- --accent: #2dd4bf;
+ --accent: #22d3ee;
--accent-fg: #04110d;
}
html[data-scheme="forest"][data-theme="light"] {
@@ -876,11 +933,11 @@ html[data-scheme="forest"][data-theme="light"] {
--border: #d6e7e0;
--fg: #102a22;
--muted: #4d6b60;
- --accent: #0d9488;
+ --accent: #0e7490;
--accent-fg: #ffffff;
}
-/* Slate — neutral grey with warm orange accent (muted in dark) */
+/* Slate — neutral grey with a fuchsia accent (E28 B2: orange read as warn) */
html[data-scheme="slate"][data-theme="dark"] {
--bg: #15171c;
--surface: #1b1e25;
@@ -888,8 +945,8 @@ html[data-scheme="slate"][data-theme="dark"] {
--border: #2c313b;
--fg: #e7e9ee;
--muted: #9aa1ad;
- --accent: #e8913c;
- --accent-fg: #1a1206;
+ --accent: #e879f9;
+ --accent-fg: #1a0620;
}
html[data-scheme="slate"][data-theme="light"] {
--bg: #f6f7f9;
@@ -898,55 +955,11 @@ html[data-scheme="slate"][data-theme="light"] {
--border: #e3e6eb;
--fg: #1c1f26;
--muted: #5d636e;
- --accent: #d97706;
+ --accent: #a21caf;
--accent-fg: #ffffff;
}
-/* YouTube — near-black with red accent */
-html[data-scheme="youtube"][data-theme="dark"] {
- --bg: #0f0f0f;
- --surface: #181818;
- --card: #1f1f1f;
- --border: #303030;
- --fg: #f1f1f1;
- --muted: #aaaaaa;
- --accent: #ff3b46;
- --accent-fg: #ffffff;
-}
-html[data-scheme="youtube"][data-theme="light"] {
- --bg: #ffffff;
- --surface: #f9f9f9;
- --card: #ffffff;
- --border: #e5e5e5;
- --fg: #0f0f0f;
- --muted: #606060;
- --accent: #ff0033;
- --accent-fg: #ffffff;
-}
-
-/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */
-html[data-scheme="starship"][data-theme="dark"] {
- --bg: #050b16;
- --surface: #0b1728;
- --card: #101f36;
- --border: #1f3557;
- --fg: #dfeaff;
- --muted: #8aa6cc;
- --accent: #38b6ff;
- --accent-fg: #04101f;
-}
-html[data-scheme="starship"][data-theme="light"] {
- --bg: #eef4fb;
- --surface: #ffffff;
- --card: #ffffff;
- --border: #d3e0f0;
- --fg: #0b1b30;
- --muted: #4a5f7d;
- --accent: #0b6bd6;
- --accent-fg: #ffffff;
-}
-
-/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */
+/* Matrix — near-black with a lime accent (E28 B2: the terminal green read as success) */
html[data-scheme="matrix"][data-theme="dark"] {
--bg: #030a06;
--surface: #08130d;
@@ -954,7 +967,7 @@ html[data-scheme="matrix"][data-theme="dark"] {
--border: #163d27;
--fg: #cfe8d6;
--muted: #6fae86;
- --accent: #46b87c;
+ --accent: #a3e635;
--accent-fg: #04170c;
}
html[data-scheme="matrix"][data-theme="light"] {
@@ -964,7 +977,7 @@ html[data-scheme="matrix"][data-theme="light"] {
--border: #cfe6d6;
--fg: #08130b;
--muted: #4c6b56;
- --accent: #12a150;
+ --accent: #4d7c0f;
--accent-fg: #ffffff;
}
@@ -986,7 +999,9 @@ html[data-theme="dark"] {
--info-fg: #08172e;
}
html[data-theme="light"] {
- --danger: #dc2626;
+ /* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted
+ light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */
+ --danger: #b91c1c;
--danger-fg: #ffffff;
--warn: #b45309;
--warn-fg: #ffffff;
diff --git a/frontend/src/design-lab/labColor.ts b/frontend/src/lib/colorMath.ts
similarity index 61%
rename from frontend/src/design-lab/labColor.ts
rename to frontend/src/lib/colorMath.ts
index fbc98764..c5d072f0 100644
--- a/frontend/src/design-lab/labColor.ts
+++ b/frontend/src/lib/colorMath.ts
@@ -1,7 +1,7 @@
-// ── Fixed-palette maths ──────────────────────────────────────────────────────────────────────────
-// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on
-// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far
-// apart, unlike HSL's.
+// WCAG contrast and OKLCH hue distance on literal hex colours. The design constitution's colour rules
+// (docs/DESIGN.md, Tokens) are measured with these: theme.contract.test.ts runs them over every scheme
+// in index.css, and the design lab's scheme tiles render the same numbers. OKLCH, because equal hue
+// angles there look equally far apart, unlike HSL's.
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
@@ -31,3 +31,13 @@ export function hueGap(a: string, b: string): number {
const d = Math.abs(hue(a) - hue(b)) % 360;
return Math.min(d, 360 - d);
}
+
+/** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the
+ * browser does in the srgb space. */
+export function mixHex(a: string, b: string, w: number): string {
+ const [x, y] = [rgb(a), rgb(b)];
+ return `#${x
+ .map((v, i) => Math.round((v * w + (y[i] ?? 0) * (1 - w)) * 255))
+ .map((n) => n.toString(16).padStart(2, "0"))
+ .join("")}`;
+}
diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts
new file mode 100644
index 00000000..0ef21cbf
--- /dev/null
+++ b/frontend/src/lib/theme.contract.test.ts
@@ -0,0 +1,76 @@
+import { existsSync, readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { hexContrast, hueGap, mixHex } from "./colorMath";
+import { SCHEMES } from "./theme";
+
+// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css:
+// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on
+// an accent fill [WCAG 1.4.3];
+// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11];
+// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never
+// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did).
+// The status colours are text too, so they are held to 4.5:1 on every scheme's page as well.
+// A new scheme, or a retuned colour, has to pass this before it ships.
+
+const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
+type Mode = "dark" | "light";
+const MODES: Mode[] = ["dark", "light"];
+
+/** The `--name: #hex;` declarations of every block whose selector is exactly `selector`, merged in
+ * source order (the same selector also carries non-colour blocks, e.g. `color-scheme`). */
+function block(selector: string): Record {
+ const out: Record = {};
+ for (let at = css.indexOf(`${selector} {`); at >= 0; at = css.indexOf(`${selector} {`, at + 1)) {
+ const body = css.slice(at, css.indexOf("}", at));
+ for (const m of body.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) out[m[1]!] = m[2]!;
+ }
+ expect(Object.keys(out), `no colour tokens for ${selector} in index.css`).not.toHaveLength(0);
+ return out;
+}
+
+const status = (mode: Mode) => block(`html[data-theme="${mode}"]`);
+const scheme = (id: string, mode: Mode) => block(`html[data-scheme="${id}"][data-theme="${mode}"]`);
+
+it("styles exactly the schemes the app offers", () => {
+ const inCss = new Set(
+ [...css.matchAll(/html\[data-scheme="(\w+)"\]\[data-theme="dark"\]/g)].map((m) => m[1]),
+ );
+ expect([...inCss].sort()).toEqual(SCHEMES.map((s) => s.id).sort());
+ // The edge token is defined the way this test measures it.
+ expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/);
+});
+
+describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
+ it.each(MODES)("%s: backdrop rule and file", (mode) => {
+ const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
+ expect(css).toContain(`url("/${file}")`);
+ expect(existsSync(new URL(`../../public/${file}`, import.meta.url)), file).toBe(true);
+ });
+
+ it.each(MODES)("%s: text and control contrast", (mode) => {
+ const t = scheme(id, mode);
+ const s = status(mode);
+ const bg = t.bg!;
+ const measured = {
+ fg: hexContrast(t.fg!, bg),
+ muted: hexContrast(t.muted!, bg),
+ accentText: hexContrast(t.accent!, bg),
+ onAccent: hexContrast(t["accent-fg"]!, t.accent!),
+ edge: hexContrast(mixHex(t.fg!, bg, 0.55), bg),
+ danger: hexContrast(s.danger!, bg),
+ warn: hexContrast(s.warn!, bg),
+ success: hexContrast(s.success!, bg),
+ };
+ const failing = Object.entries(measured).filter(([k, v]) => v < (k === "edge" ? 3 : 4.5));
+ expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
+ });
+
+ it.each(MODES)("%s: the accent never looks like a status", (mode) => {
+ const accent = scheme(id, mode).accent!;
+ const s = status(mode);
+ const close = (["danger", "warn", "success"] as const)
+ .map((k) => [k, hueGap(accent, s[k]!)] as const)
+ .filter(([, gap]) => gap < 30);
+ expect(close.map(([k, gap]) => `${k} ${gap.toFixed(0)}°`)).toEqual([]);
+ });
+});
diff --git a/frontend/src/lib/theme.test.ts b/frontend/src/lib/theme.test.ts
new file mode 100644
index 00000000..dfe647f8
--- /dev/null
+++ b/frontend/src/lib/theme.test.ts
@@ -0,0 +1,30 @@
+import { expect, it } from "vitest";
+import { DEFAULT_THEME, normalizeTheme } from "./theme";
+
+// YouTube and Starship were retired in E28 with no data migration (owner, 2026-09-24): an account or
+// a browser that still stores one must land on the default scheme, not on an html[data-scheme] that no
+// CSS block styles (an unstyled page), and the retired tuner flag must not ride along into the next save.
+it("maps a retired scheme to the default and keeps only the known keys", () => {
+ const t = normalizeTheme({
+ mode: "light",
+ scheme: "youtube",
+ fontScale: 1.1,
+ bgImage: false,
+ bgFade: 35,
+ ...{ showTuner: true },
+ });
+ expect(t).toEqual({
+ mode: "light",
+ scheme: "midnight",
+ fontScale: 1.1,
+ bgImage: false,
+ bgFade: 35,
+ });
+});
+
+it("keeps a live scheme and fills what is missing or malformed from the defaults", () => {
+ expect(normalizeTheme({ scheme: "matrix", mode: "sepia", fontScale: Number.NaN })).toEqual({
+ ...DEFAULT_THEME,
+ scheme: "matrix",
+ });
+});
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts
index dd00f66a..cca27b92 100644
--- a/frontend/src/lib/theme.ts
+++ b/frontend/src/lib/theme.ts
@@ -1,15 +1,16 @@
import { LS, readAccountMerged, writeAccount } from "./storage";
type Mode = "dark" | "light";
-export type Scheme = "midnight" | "forest" | "slate" | "youtube" | "starship" | "matrix";
+export type Scheme = "midnight" | "forest" | "slate" | "matrix";
+// The scheme set (docs/DESIGN.md, decision B2): each earns its place with its own hue family, and its
+// accent stays ≥ 30° of OKLCH hue away from danger / warn / success. theme.contract.test.ts measures
+// every scheme's tokens in index.css against that rule and WCAG contrast.
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
- { id: "forest", name: "Forest", swatch: "#2dd4bf" },
- { id: "slate", name: "Slate", swatch: "#e8913c" },
- { id: "youtube", name: "YouTube", swatch: "#ff3b46" },
- { id: "starship", name: "Starship", swatch: "#38b6ff" },
- { id: "matrix", name: "Matrix", swatch: "#46b87c" },
+ { id: "forest", name: "Forest", swatch: "#22d3ee" },
+ { id: "slate", name: "Slate", swatch: "#e879f9" },
+ { id: "matrix", name: "Matrix", swatch: "#a3e635" },
];
export interface ThemePrefs {
@@ -20,8 +21,6 @@ export interface ThemePrefs {
bgImage: boolean;
/** How strongly the background image is faded out, 0–100% (higher = fainter). */
bgFade: number;
- /** Show the floating glass/theme tuner (a secondary "geek toy"); off by default. */
- showTuner: boolean;
}
export const DEFAULT_THEME: ThemePrefs = {
@@ -30,19 +29,34 @@ export const DEFAULT_THEME: ThemePrefs = {
fontScale: 1.06,
bgImage: true,
bgFade: 60,
- showTuner: false,
};
+/** A stored theme (this browser's copy or the account's server prefs) as the app can apply it: only
+ * the known keys, and a scheme or mode that no longer exists (YouTube and Starship were retired in
+ * E28) falls back to the default instead of painting an unstyled page. */
+export function normalizeTheme(raw: Partial>): ThemePrefs {
+ const d = DEFAULT_THEME;
+ const num = (v: unknown, fallback: number) =>
+ typeof v === "number" && Number.isFinite(v) ? v : fallback;
+ return {
+ mode: raw.mode === "light" || raw.mode === "dark" ? raw.mode : d.mode,
+ scheme: SCHEMES.some((s) => s.id === raw.scheme) ? (raw.scheme as Scheme) : d.scheme,
+ fontScale: num(raw.fontScale, d.fontScale),
+ bgImage: typeof raw.bgImage === "boolean" ? raw.bgImage : d.bgImage,
+ bgFade: num(raw.bgFade, d.bgFade),
+ };
+}
+
export function applyTheme(t: ThemePrefs): void {
const el = document.documentElement;
el.dataset.theme = t.mode;
el.dataset.scheme = t.scheme;
el.style.setProperty("--font-scale", String(t.fontScale));
- if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`);
+ el.style.setProperty("--bg-fade", `${t.bgFade}%`);
}
export function loadLocalTheme(): ThemePrefs {
- return readAccountMerged(LS.theme, DEFAULT_THEME);
+ return normalizeTheme(readAccountMerged(LS.theme, DEFAULT_THEME));
}
export function saveLocalTheme(t: ThemePrefs): void {
From 7d480e2ad0f192d733ac7f2a90ffbb30c82a2bd8 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 04:49:07 +0200
Subject: [PATCH 009/137] feat(design): Matrix toned down to moss, text on an
accent fill held to --accent-fg
The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the
moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed
white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use
--accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent.
---
docs/DESIGN.md | 3 ++-
frontend/public/backdrops/light/matrix.svg | 10 +++----
frontend/public/backdrops/matrix.svg | 10 +++----
frontend/src/components/PlexInfo.tsx | 2 +-
frontend/src/components/PlexPlaylistView.tsx | 2 +-
frontend/src/components/UpNextCard.tsx | 2 +-
frontend/src/components/plexCards.tsx | 2 +-
frontend/src/components/plexDetailUi.tsx | 2 +-
frontend/src/components/ui/form.tsx | 4 +--
frontend/src/design-lab/batchA.tsx | 4 +--
frontend/src/design-lab/batchB.tsx | 8 +++---
frontend/src/index.css | 28 +++++++++++---------
frontend/src/lib/theme.contract.test.ts | 19 ++++++++++++-
frontend/src/lib/theme.ts | 2 +-
14 files changed, 59 insertions(+), 39 deletions(-)
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 17c48a41..c406a549 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -70,7 +70,7 @@ style guide.
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
--warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark
- and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (lime). Each earns its
+ and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its
place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4).
- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
@@ -302,3 +302,4 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
+| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
diff --git a/frontend/public/backdrops/light/matrix.svg b/frontend/public/backdrops/light/matrix.svg
index 9ef8303c..f794cacf 100644
--- a/frontend/public/backdrops/light/matrix.svg
+++ b/frontend/public/backdrops/light/matrix.svg
@@ -1,13 +1,13 @@
-
-
-
-
+
+
+
+
-
+
diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg
index 038ecfc8..b5380ffa 100644
--- a/frontend/public/backdrops/matrix.svg
+++ b/frontend/public/backdrops/matrix.svg
@@ -1,13 +1,13 @@
-
-
-
-
+
+
+
+
-
+
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index e85f5bd1..84709be4 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -594,7 +594,7 @@ export default function PlexInfo({
{onPlay && (
{inProgress ? t("plex.resume") : t("plex.play")}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index 3290d90f..ee942539 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -569,7 +569,7 @@ export default function PlexPlaylistView({
{items.length > 0 && (
onPlay(order[0]!, order)}
- className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
+ className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90"
>
{t("plex.playlist.playAll")}
diff --git a/frontend/src/components/UpNextCard.tsx b/frontend/src/components/UpNextCard.tsx
index 5a618322..e2901bcf 100644
--- a/frontend/src/components/UpNextCard.tsx
+++ b/frontend/src/components/UpNextCard.tsx
@@ -86,7 +86,7 @@ export function UpNextCard({
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx
index 67ec246e..7b153972 100644
--- a/frontend/src/components/plexCards.tsx
+++ b/frontend/src/components/plexCards.tsx
@@ -140,7 +140,7 @@ export function PlexPosterCard({
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx
index f2a8c6fc..11b6aff2 100644
--- a/frontend/src/components/ui/form.tsx
+++ b/frontend/src/components/ui/form.tsx
@@ -43,8 +43,8 @@ export function Switch({
className={`w-9 h-5 rounded-full transition relative shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${checked ? "bg-accent" : "bg-border"}`}
>
diff --git a/frontend/src/design-lab/batchA.tsx b/frontend/src/design-lab/batchA.tsx
index 4009c01d..7f9e48f9 100644
--- a/frontend/src/design-lab/batchA.tsx
+++ b/frontend/src/design-lab/batchA.tsx
@@ -104,7 +104,7 @@ function TouchRow() {
Dark mode
-
+
@@ -136,7 +136,7 @@ function NativeSet() {
Hide Shorts
-
+
diff --git a/frontend/src/design-lab/batchB.tsx b/frontend/src/design-lab/batchB.tsx
index b3418d02..50749328 100644
--- a/frontend/src/design-lab/batchB.tsx
+++ b/frontend/src/design-lab/batchB.tsx
@@ -81,9 +81,9 @@ const SCHEMES: SchemeDef[] = [
light: pal("#f6f7f9", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e", "#a21caf", "#ffffff"),
},
{
- name: "Matrix · lime",
- dark: pal("#030a06", "#08130d", "#163d27", "#cfe8d6", "#6fae86", "#a3e635", "#04170c"),
- light: pal("#f0f7f1", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56", "#4d7c0f", "#ffffff"),
+ name: "Matrix · moss",
+ dark: pal("#0b0e0b", "#111510", "#2a3226", "#d9ded6", "#8f9a8a", "#86a05a", "#0c1208"),
+ light: pal("#f4f5f0", "#ffffff", "#dfe3d8", "#161a14", "#5a6352", "#556b2f", "#ffffff"),
},
];
@@ -472,7 +472,7 @@ export function BatchB() {
id="b2"
code="B2"
title="The scheme set"
- rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix lime. YouTube and Starship are gone (a user on them lands on Midnight). Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light."
+ rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix moss. YouTube and Starship are gone (a user on them lands on Midnight). Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light."
>
{SCHEMES.flatMap((s) =>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a2d21f3a..189836a8 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -960,24 +960,26 @@ html[data-scheme="slate"][data-theme="light"] {
}
/* Matrix — near-black with a lime accent (E28 B2: the terminal green read as success) */
+/* Matrix is moss, not neon (owner, 2026-09-24): the accent sits at Midnight's lightness (OKLCH L 0.67)
+ with half the old lime's chroma, and the neutrals are barely tinted, so the page doesn't glow green. */
html[data-scheme="matrix"][data-theme="dark"] {
- --bg: #030a06;
- --surface: #08130d;
- --card: #0b1c12;
- --border: #163d27;
- --fg: #cfe8d6;
- --muted: #6fae86;
- --accent: #a3e635;
- --accent-fg: #04170c;
+ --bg: #0b0e0b;
+ --surface: #111510;
+ --card: #161b15;
+ --border: #2a3226;
+ --fg: #d9ded6;
+ --muted: #8f9a8a;
+ --accent: #86a05a;
+ --accent-fg: #0c1208;
}
html[data-scheme="matrix"][data-theme="light"] {
- --bg: #f0f7f1;
+ --bg: #f4f5f0;
--surface: #ffffff;
--card: #ffffff;
- --border: #cfe6d6;
- --fg: #08130b;
- --muted: #4c6b56;
- --accent: #4d7c0f;
+ --border: #dfe3d8;
+ --fg: #161a14;
+ --muted: #5a6352;
+ --accent: #556b2f;
--accent-fg: #ffffff;
}
diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts
index 0ef21cbf..64bc24a5 100644
--- a/frontend/src/lib/theme.contract.test.ts
+++ b/frontend/src/lib/theme.contract.test.ts
@@ -1,4 +1,4 @@
-import { existsSync, readFileSync } from "node:fs";
+import { existsSync, readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { hexContrast, hueGap, mixHex } from "./colorMath";
import { SCHEMES } from "./theme";
@@ -40,6 +40,23 @@ it("styles exactly the schemes the app offers", () => {
expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/);
});
+// The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent
+// fill passes on one scheme and fails on the next (white on the old Matrix lime was 1.5:1).
+it("text on a solid accent fill uses --accent-fg", () => {
+ const root = new URL("../", import.meta.url);
+ const offenders: string[] = [];
+ for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) {
+ if (!f.endsWith(".tsx")) continue;
+ readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")
+ .split("\n")
+ .forEach((line, i) => {
+ if (/\bbg-accent(?![\w/-])/.test(line) && /\b(text|bg)-(white|black)\b/.test(line))
+ offenders.push(`${f}:${i + 1}`);
+ });
+ }
+ expect(offenders).toEqual([]);
+});
+
describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
it.each(MODES)("%s: backdrop rule and file", (mode) => {
const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts
index cca27b92..4246c0a9 100644
--- a/frontend/src/lib/theme.ts
+++ b/frontend/src/lib/theme.ts
@@ -10,7 +10,7 @@ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
{ id: "forest", name: "Forest", swatch: "#22d3ee" },
{ id: "slate", name: "Slate", swatch: "#e879f9" },
- { id: "matrix", name: "Matrix", swatch: "#a3e635" },
+ { id: "matrix", name: "Matrix", swatch: "#86a05a" },
];
export interface ThemePrefs {
From 86f467bd65a9b4401f33d45ebc60f546a26dc0e8 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 05:25:55 +0200
Subject: [PATCH 010/137] feat(design): the button families on the decided
ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.
Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
---
CLAUDE.md | 4 +-
docs/DESIGN.md | 17 +-
frontend/src/components/AddToPlaylist.tsx | 19 +-
frontend/src/components/AdminUsers.tsx | 176 +++++++++---------
frontend/src/components/ChannelPage.tsx | 13 +-
frontend/src/components/ChannelTags.tsx | 14 +-
frontend/src/components/ChatThread.tsx | 15 +-
frontend/src/components/DownloadButton.tsx | 24 ++-
frontend/src/components/DownloadCenter.tsx | 14 +-
frontend/src/components/Feed.tsx | 62 +++---
frontend/src/components/IssueComposer.tsx | 25 +--
frontend/src/components/Messages.tsx | 23 ++-
frontend/src/components/NavSidebar.tsx | 53 +++---
.../src/components/NotificationsPanel.tsx | 35 +---
.../src/components/PersonalViewsModal.tsx | 83 ++++-----
frontend/src/components/PlayerModal.tsx | 136 ++++++--------
frontend/src/components/Playlists.tsx | 22 +--
frontend/src/components/PlexInfo.tsx | 95 +++++-----
frontend/src/components/PlexPlaylistView.tsx | 148 +++++++--------
frontend/src/components/Scheduler.tsx | 63 +++----
frontend/src/components/SettingsPanel.tsx | 39 ++--
frontend/src/components/ShareDialog.tsx | 41 ++--
frontend/src/components/VideoCard.tsx | 143 +++++++-------
frontend/src/components/VideoEditor.tsx | 91 ++++-----
frontend/src/components/Welcome.tsx | 73 ++++----
frontend/src/components/plexCards.tsx | 78 ++++----
frontend/src/components/plexDetailUi.tsx | 58 +++---
frontend/src/components/ui/Banner.tsx | 15 +-
.../src/components/ui/Button.dom.test.tsx | 86 ++++++---
frontend/src/components/ui/Button.tsx | 173 ++++++++---------
frontend/src/components/ui/DragHandle.tsx | 40 ++++
.../src/components/ui/FocusSafeButton.tsx | 5 +-
.../src/components/ui/IconButton.dom.test.tsx | 138 ++++++++++++++
frontend/src/components/ui/IconButton.tsx | 109 +++++++++++
frontend/src/components/ui/Modal.tsx | 14 +-
frontend/src/components/ui/Pressable.tsx | 35 ++++
frontend/src/components/ui/README.md | 13 +-
frontend/src/components/ui/SidePanel.tsx | 25 ++-
frontend/src/design-lab/batchA.tsx | 89 +++------
frontend/src/design-lab/labTokens.ts | 1 -
frontend/src/index.css | 16 +-
frontend/src/lib/theme.contract.test.ts | 17 ++
42 files changed, 1304 insertions(+), 1036 deletions(-)
create mode 100644 frontend/src/components/ui/DragHandle.tsx
create mode 100644 frontend/src/components/ui/IconButton.dom.test.tsx
create mode 100644 frontend/src/components/ui/IconButton.tsx
create mode 100644 frontend/src/components/ui/Pressable.tsx
diff --git a/CLAUDE.md b/CLAUDE.md
index 846ffe0f..53e6a54f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,7 +81,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
-| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
+| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
@@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
-| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `` |
+| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) | a hand-rolled `` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `` |
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index c406a549..fedbf71b 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -111,7 +111,9 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
- picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only.
+ picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only: a text
+ button on a picture is a pill (the card's Resume in the accent, From the start dark translucent, C5), and
+ the one 48px glyph is the Play in the middle of a not-started card (C5).
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
sizes don't change. [HIG, WCAG 2.5.8]
@@ -125,8 +127,16 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
Also in force:
-- An action button is `ui/Button` with a `SIZES` rung. A hand-rolled `` is a
- violation. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2]
+- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
+ plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
+ required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
+ tag chip is the chip family; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
+ `Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme. A pressed
+ toggle is `pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A hand-rolled
+ `` is a violation. A text-less button must have an accessible name, and the
+ type enforces it. [WCAG 4.1.2]
+- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
+ never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
Never a native `` (lint-enforced).
- A filter chip goes through `FilterChipGroup`.
@@ -303,3 +313,4 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
+| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx
index 6a6d1587..c58459cb 100644
--- a/frontend/src/components/AddToPlaylist.tsx
+++ b/frontend/src/components/AddToPlaylist.tsx
@@ -9,6 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
+import { IconButton } from "./ui/IconButton";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -16,10 +17,12 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
export default function AddToPlaylist({
videoId,
- className,
+ variant = "ghost",
+ size = "sm",
}: {
videoId: string;
- className?: string;
+ variant?: "ghost" | "outline" | "overlay";
+ size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -90,8 +93,10 @@ export default function AddToPlaylist({
return (
<>
-
-
-
+
+
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index 78c7d3d6..7ebb064c 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -29,6 +29,7 @@ import { formatDateTime, relativeTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { Section } from "./ui/form";
@@ -42,7 +43,7 @@ import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { Pressable } from "./ui/Pressable";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -230,17 +231,17 @@ function UsersRoles({ me }: { me: Me }) {
const self = u.id === me.id;
return (
-
setPanelFor(u)}
title={t("users.account.open")}
- className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
+ className="selectable cursor-pointer min-w-0 flex-1 rounded-lg -m-1 p-1"
>
{displayName(u)}
{self && · {t("users.roles.you")} }
{u.email}
-
+
{u.role === "admin" &&
{t("users.roles.admin")} }
{u.is_demo &&
{t("users.roles.demo")} }
{u.is_suspended &&
{t("users.roles.suspended")} }
@@ -283,36 +284,36 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.suspend.action")
}
>
-
onSuspend(u)}
- disabled={u.is_demo || self || pendingIds.has(u.id)}
- aria-label={
+
- ) : (
-
- )
- }
- />
+ size="sm"
+ variant={u.is_suspended ? "outline-warn" : "outline"}
+ onClick={() => onSuspend(u)}
+ disabled={u.is_demo || self || pendingIds.has(u.id)}
+ >
+ {u.is_suspended ? (
+
+ ) : (
+
+ )}
+
- onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
- aria-label={t("users.delete.action")}
- icon={ }
- />
+ >
+
+
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
@@ -459,15 +460,16 @@ function PendingAccessRowView({
{!row.email_verified && (
- }
- />
+ >
+
+
)}
{row.can_activate && (
@@ -729,18 +731,15 @@ function AdminInvites() {
: t("settings.invites.inviteHint")
}
>
- onInvite(i)}
disabled={invite.isPending}
- className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
- i.invited_at
- ? "text-muted/50 hover:text-fg"
- : "text-muted hover:text-accent"
- }`}
- aria-label={t("settings.invites.inviteLabel", { email: i.email })}
+ label={t("settings.invites.inviteLabel", { email: i.email })}
>
-
-
+ {/* Already invited: the glyph fades, the action stays (a resend). */}
+
+
)}
{/* An entry with an account behind it stays read-only: deleting it would revoke that
@@ -748,26 +747,24 @@ function AdminInvites() {
{i.has_account ? (
// `disabled` would drop it from the tab order AND suppress the title, so the
// reason reached nobody — same call as the Playlists drag handle.
- e.preventDefault()}
- title={t("settings.invites.deleteHasAccount")}
- aria-label={t("settings.invites.deleteHasAccount")}
- className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
+ label={t("settings.invites.deleteHasAccount")}
>
-
-
+
+
) : (
- onDelete(i)}
disabled={remove.isPending}
- className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
- aria-label={t("settings.invites.deleteLabel", { email: i.email })}
+ label={t("settings.invites.deleteLabel", { email: i.email })}
>
-
-
+
+
)}
@@ -841,15 +838,16 @@ function AdminDemo() {
)}
- onRemove(r)}
disabled={remove.isPending}
- aria-label={t("settings.demo.remove")}
- icon={ }
- />
+ >
+
+
))}
@@ -877,7 +875,11 @@ function AdminDemo() {
- }
+ loading={reset.isPending}
onClick={async () => {
if (
await confirm({
@@ -889,12 +891,9 @@ function AdminDemo() {
)
reset.mutate();
}}
- disabled={reset.isPending}
- className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
-
{t("settings.demo.reset")}
-
+
@@ -928,26 +927,28 @@ function InviteRow({
)}
- }
- />
+ >
+
+
- }
- />
+ >
+
+
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
@@ -955,27 +956,26 @@ function InviteRow({
silently missing a button their other rows have. */}
{inv.has_account ? (
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
-
e.preventDefault()}
- title={t("settings.invites.deleteHasAccount")}
- aria-label={t("settings.invites.deleteHasAccount")}
- className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
+ label={t("settings.invites.deleteHasAccount")}
>
-
-
+
+
) : (
- }
- />
+ >
+
+
)}
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index 0075a98a..5362bf5f 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -37,6 +37,7 @@ import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
@@ -436,14 +437,14 @@ export default function ChannelPage({
{ch?.subscribed && (
-
}
- />
+ >
+
+
)}
{ch?.subscribed && !me.is_demo && (
diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx
index d3a42483..2dcd643e 100644
--- a/frontend/src/components/ChannelTags.tsx
+++ b/frontend/src/components/ChannelTags.tsx
@@ -7,7 +7,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { IconButton } from "./ui/IconButton";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -278,16 +278,14 @@ function TagEditorMenu({
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
-
-
-
+
+
);
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx
index a16e5753..25500589 100644
--- a/frontend/src/components/ChatThread.tsx
+++ b/frontend/src/components/ChatThread.tsx
@@ -6,7 +6,7 @@ import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
-import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
@@ -223,16 +223,15 @@ export default function ChatThread({
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
- }
- />
+ >
+
+
)}
>
diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx
index c58386af..93230492 100644
--- a/frontend/src/components/DownloadButton.tsx
+++ b/frontend/src/components/DownloadButton.tsx
@@ -8,6 +8,7 @@ import clsx from "clsx";
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api";
+import { IconButton } from "./ui/IconButton";
// Self-contained download affordance for a video card / the player. Hidden for the demo account
// (downloads spend server disk + need a real identity). Reflects the video's current state from
@@ -15,11 +16,13 @@ import { api, type Me } from "../lib/api";
export default function DownloadButton({
videoId,
title,
- className,
+ variant = "ghost",
+ size = "sm",
}: {
videoId: string;
title?: string | null;
- className?: string;
+ variant?: "ghost" | "outline" | "overlay";
+ size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -47,20 +50,21 @@ export default function DownloadButton({
return (
<>
-
{
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
- title={label}
- className={clsx(
- className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
- (downloaded || inQueue) && "text-accent",
- )}
+ label={label}
>
-
-
+ {/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
+
+
{open && (
setOpen(false)} />
diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx
index d65f0f92..ad0c61ca 100644
--- a/frontend/src/components/DownloadCenter.tsx
+++ b/frontend/src/components/DownloadCenter.tsx
@@ -39,6 +39,7 @@ import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
@@ -199,15 +200,14 @@ export default function DownloadCenter() {
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
- }
+ pasteFromClipboard(true)}
- className="shrink-0"
- />
+ >
+
+
import("./PlayerModal"));
@@ -436,7 +436,10 @@ export default function Feed({
-
}
onClick={() => {
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance.
@@ -444,11 +447,9 @@ export default function Feed({
onExitYtSearch();
dropFeedCaches();
}}
- className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
>
-
{t("feed.yt.back")}
-
+
@@ -484,7 +485,10 @@ export default function Feed({
{t("feed.yt.ephemeralNote")}
- }
onClick={() => {
const ids = ytItems.map((v) => v.id);
api
@@ -500,11 +504,9 @@ export default function Feed({
dropFeedCaches();
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
- className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
>
-
{t("feed.yt.clearNow")}
-
+
)}
@@ -530,17 +532,17 @@ export default function Feed({
/>
{ytQuery.hasNextPage && (
- ytQuery.fetchNextPage()}
- disabled={ytQuery.isFetchingNextPage}
- className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
+ loading={ytQuery.isFetchingNextPage}
>
{ytQuery.isFetchingNextPage
? t("feed.loadingMore")
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
-
+
)}
>
@@ -594,12 +596,13 @@ export default function Feed({
{t("feed.setUp")}
-
setFilters({ ...filters, scope: "all" })}
- className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
-
+
>
)}
@@ -635,14 +638,13 @@ export default function Feed({
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
-
setFilters({ ...filters, seed: rollSeed() })}
- title={t("sidebar.reshuffle")}
- aria-label={t("sidebar.reshuffle")}
- className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
+ label={t("sidebar.reshuffle")}
>
-
-
+
+
) : undefined
}
/>
@@ -757,23 +759,23 @@ export default function Feed({
{filters.channelIds.length > 0 && filters.scope === "my" && (
{t("feed.channelScopeHint")}
-
}
onClick={() => setFilters({ ...filters, scope: "all" })}
- className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
-
{t("feed.viewInLibrary")}
-
+
)}
{filters.q.trim() && !isDemo && (
-
}
onClick={() => onYtSearch(filters.q.trim())}
- className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
-
{t("feed.yt.searchFor", { query: filters.q.trim() })}
-
+
)}
) : (
diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx
index 62f71081..ee0266d3 100644
--- a/frontend/src/components/IssueComposer.tsx
+++ b/frontend/src/components/IssueComposer.tsx
@@ -21,6 +21,7 @@ import type { Page } from "../lib/urlState";
import { qk } from "../lib/queryKeys";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
@@ -519,17 +520,17 @@ function DraftFields({
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
{!readOnly && (
- }
+ {
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
addStep.current?.focus();
}}
- />
+ >
+
+
)}
))}
@@ -678,18 +679,18 @@ function Attachments({
{formatBytes(shot.size)}
{!readOnly && (
- }
+ {
remove.mutate(shot.id);
pick.current?.focus();
}}
- />
+ >
+
+
)}
))}
diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx
index e16ddf53..ca4b44d0 100644
--- a/frontend/src/components/Messages.tsx
+++ b/frontend/src/components/Messages.tsx
@@ -6,6 +6,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
@@ -221,15 +222,16 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
return (
-
}
- />
+ >
+
+
{t("messages.newTitle")}
-
}
- />
+ >
+
+
- setPage(p)}
aria-label={label}
@@ -191,7 +192,7 @@ export default function NavSidebar({
)}
-
+
);
};
@@ -220,25 +221,25 @@ export default function NavSidebar({
- }
- />
-
+
+
+ }
- />
+ >
+
+
- setPage("settings")}
aria-label={t("header.account.settings")}
@@ -246,11 +247,11 @@ export default function NavSidebar({
className={`${rowBase} justify-center px-0 is-current`}
>
-
+
-
-
+
0 && (
{otherAccounts.map((a) => (
- switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
@@ -316,18 +317,18 @@ export default function NavSidebar({
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-3xs shrink-0"
/>
-
+
{displayName(a)}
{a.email}
-
+
))}
)}
-
{
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
@@ -339,14 +340,14 @@ export default function NavSidebar({
>
{t("header.account.addAccount")}
-
-
+
{t("header.account.signOut")}
-
+
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx
index 2f828a21..52f7d3bf 100644
--- a/frontend/src/components/NotificationsPanel.tsx
+++ b/frontend/src/components/NotificationsPanel.tsx
@@ -38,6 +38,7 @@ import {
} from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import { LEVEL_STYLE } from "./ui/toastLevels";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
@@ -346,23 +347,13 @@ function NotificationRow({
{!n.read && (
- }
- />
+
+
+
)}
- }
- />
+
+
+
);
@@ -498,15 +489,9 @@ function ClientActivityRow({
)
)}
- }
- />
+
+
+
);
}
diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx
index 69d92968..18428c9c 100644
--- a/frontend/src/components/PersonalViewsModal.tsx
+++ b/frontend/src/components/PersonalViewsModal.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
+import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react";
import {
closestCenter,
DndContext,
@@ -14,13 +14,15 @@ import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import { useSavedViews, asFilters } from "./savedViewsContext";
import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./ui/UndoToolbar";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
+import { DragHandle } from "./ui/DragHandle";
+import { Pressable } from "./ui/Pressable";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
@@ -50,22 +52,13 @@ function ViewRow({
selected ? "bg-accent/15" : "hover:bg-card"
}`}
>
-
-
-
-
+
{view.name}
-
+
{view.is_default &&
}
{active &&
}
@@ -214,16 +207,15 @@ export default function PersonalViewsModal() {
placeholder={t("views.namePlaceholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
-
}
+
+ >
+
+
@@ -238,15 +230,15 @@ export default function PersonalViewsModal() {
onBlur={() => void onRename()}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
-
void onRename()}
disabled={!nameChanged}
- title={t("views.rename")}
- aria-label={t("views.rename")}
- className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-30"
+ label={t("views.rename")}
>
-
-
+
+
@@ -294,7 +286,10 @@ export default function PersonalViewsModal() {
>
{t("views.apply")}
- }
onClick={async () => {
const saved = await saveCurrent(selected.id);
// Re-seed the draft to what we just stored (the live filters) so the chip row
@@ -305,36 +300,32 @@ export default function PersonalViewsModal() {
title={
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
}
- className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-40"
>
-
{t("views.saveCurrent")}
-
-
+ void toggleDefault(selected)}
- title={t("views.default")}
- className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
- selected.is_default
- ? "border-accent text-accent"
- : "border-border text-muted hover:text-fg"
- }`}
+ icon={ }
>
-
{t("views.default")}
-
-
+ }
+ className="ml-auto"
onClick={async () => {
const next = views.find((v) => v.id !== selected.id);
// Only move the selection if the delete actually happened (remove awaits a confirm
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
if (await remove(selected)) setSelectedId(next?.id ?? null);
}}
- title={t("views.delete")}
- className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-danger transition hover:border-danger"
>
-
{t("views.delete")}
-
+
) : (
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index c3a3e8b0..ce6497ed 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -61,7 +61,9 @@ import {
useYtVolume,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -574,7 +576,7 @@ export default function PlayerModal({
where there's no room beside the card. */}
{hasQueue && (
-
{
e.stopPropagation();
goPrev();
@@ -585,7 +587,7 @@ export default function PlayerModal({
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
-
+
)}
(a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
+ {!navigated &&
}
{!navigated && (
-
+
)}
{!navigated && (
-
- )}
- {!navigated && (
-
setWatched(!watched)}
- title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
- className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
- watched
- ? "bg-white/25 text-white"
- : "text-white/70 hover:bg-white/10 hover:text-white"
- }`}
+ label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
+ icon={watched ? : }
>
- {watched ? : }
{watched ? t("player.watched") : t("player.markWatched")}
-
+
)}
-
- {isFullscreen ? (
-
- ) : (
-
- )}
-
-
:
}
+
+
{
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
- title={t("player.exitMaximize")}
- aria-label={t("player.exitMaximize")}
- className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-white/80 hover:bg-white/10 hover:text-white transition"
+ label={t("player.exitMaximize")}
+ icon={ }
>
-
{t("player.exitMaximize")}
-
+
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
@@ -802,14 +788,15 @@ export default function PlayerModal({
{navigated && (
-
loadVideo(active.id, null)}
title={t("player.backToOriginal")}
- className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
+ icon={ }
>
-
{t("player.back")}
-
+
)}
{t("player.noDescription")}
)}
-
{t("player.close")}
-
-
+
+
{/* Channel + meta on one line, with the watched toggle pushed to the right.
@@ -952,60 +940,56 @@ export default function PlayerModal({
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
the docked bar offers OUR true fullscreen. */}
{!navigated && (
-
-
-
- )}
- {!navigated && (
-
+
+
)}
+ {!navigated &&
}
{!navigated && (
)}
{!navigated && onRemoveFromList && (
-
void removeFromList()}
- title={t("playlists.removeItem")}
// Named with its object, like the row control: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
- aria-label={t("playlists.removeItemOf", { title: active.title })}
- className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition"
+ label={t("playlists.removeItemOf", { title: active.title })}
+ title={t("playlists.removeItem")}
>
-
-
+
+
)}
{!navigated && (
-
setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
- className={
- "shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
- (watched
- ? "btn-primary shadow-xs"
- : "text-muted hover:text-fg hover:bg-surface border border-border")
- }
+ icon={watched ? : }
>
- {watched ? : }
{watched ? t("player.watched") : t("player.markWatched")}
-
+
)}
{hasQueue && (
- {
e.stopPropagation();
goNext();
@@ -1016,7 +1000,7 @@ export default function PlayerModal({
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
-
+
)}
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index 20f8f7d8..4d53dc01 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -49,6 +49,7 @@ import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinVideo } from "./comfortPin";
@@ -964,26 +965,25 @@ export default function Playlists() {
)}
{linked && detail.dirty && (
-
-
+
)}
{editable && (
- }
- />
+ >
+
+
)}
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 84709be4..f77c3589 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -15,7 +15,9 @@ import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { navItemAttrs } from "../lib/listNav";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -59,14 +61,14 @@ function Score({
}) {
const { t } = useTranslation();
return filterable && onFilterRating ? (
- onFilterRating(Math.floor(score))}
- className="inline-flex items-center gap-1 hover:opacity-80"
+ className="inline-flex items-center gap-1 hover:underline"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
{score.toFixed(1)}
-
+
) : (
@@ -257,13 +259,13 @@ function CrewPanel({
{crew.map((c, i) => (
{onOpenPerson ? (
-
onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
- className="truncate text-left text-sm font-medium hover:text-accent"
+ className="max-w-full truncate text-sm font-medium hover:text-accent"
>
{c.name}
-
+
) : (
{c.name}
)}
@@ -471,13 +473,14 @@ export default function PlexInfo({
}
/>
{overlay && onClose && (
-
-
-
+
+
)}
@@ -592,57 +595,41 @@ export default function PlexInfo({
{!overlay && (
{onPlay && (
-
-
+ }>
{inProgress ? t("plex.resume") : t("plex.play")}
-
+
)}
{detail.status === "watched" ? (
-
setState("new")}
- className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
- >
-
+ setState("new")} icon={ }>
{t("plex.info.markUnwatched")}
-
+
) : (
-
setState("watched")}
- className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
- >
-
+ setState("watched")} icon={ }>
{t("plex.info.markWatched")}
-
+
)}
{inProgress && (
-
setState("new")}
- className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
- >
-
+ setState("new")} icon={ }>
{t("plex.info.clearResume")}
-
+
)}
{detail.kind === "movie" && (
-
setPlaylistOpen(true)}
- className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
+ icon={ }
>
-
{t("plex.playlistAdd.manage")}
-
+
)}
{isAdmin && detail.kind === "movie" && library && (
-
setEditorOpen(true)}
- className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
+ icon={ }
>
-
{t("plex.collEditor.manage")}
-
+
)}
)}
@@ -709,8 +696,9 @@ export default function PlexInfo({
>
);
return clickable ? (
-
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
@@ -720,10 +708,10 @@ export default function PlexInfo({
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
- className="group/cast w-20 shrink-0 text-center"
+ className="group/cast w-20 shrink-0"
>
{inner}
-
+
) : (
{inner}
@@ -747,27 +735,28 @@ export default function PlexInfo({
{col.title}
{onFilter && (
-
onFilter({
collections: [col.id],
collectionTitles: { [col.id]: col.title },
})
}
- className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
-
+
)}
{col.items.map((m, i) => (
- onPlayItem?.(m.id)}
disabled={!onPlayItem}
- className="group/col w-28 shrink-0 text-left"
+ className="group/col w-28 shrink-0"
>
{m.year ?
{m.year}
: null}
-
+
))}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index ee942539..e4ac92e9 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -16,7 +16,6 @@ import {
ArrowLeft,
ChevronDown,
ChevronRight,
- GripVertical,
ListTree,
Pencil,
Play,
@@ -32,7 +31,9 @@ import { useConfirm } from "./confirmContext";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
+import { DragHandle } from "./ui/DragHandle";
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
import {
sameListCollision,
@@ -128,21 +129,6 @@ function Poster({ src, className }: { src: string; className: string }) {
);
}
-/** `title` names the row it moves: a screen reader heard the English word "reorder" on every
- * row, with nothing to say which. */
-function DragHandle({ title, ...props }: { title: string } & Record) {
- const { t } = useTranslation();
- return (
-
-
-
- );
-}
-
function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
@@ -161,13 +147,17 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
style={style}
className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`}
>
-
+
{ep.episode_number}
- ctx.onPlay(ep.id)}
- className="group flex min-w-0 flex-1 items-center gap-2.5 text-left"
+ className="group flex min-w-0 flex-1 items-center gap-2.5"
>
{ep.title}
-
-
+ ctx.onRemove([ep.id])}
disabled={ctx.busy}
- className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
- aria-label={t("plex.playlist.removeItem", { title: ep.title })}
+ label={t("plex.playlist.removeItem", { title: ep.title })}
>
-
-
+
+
);
}
@@ -215,10 +205,14 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
style={style}
className="glass-card flex items-center gap-3 rounded-xl p-2"
>
-
-
+ ctx.onPlay(item.id)}
- className="group flex min-w-0 flex-1 items-center gap-3 text-left"
+ className="group flex min-w-0 flex-1 items-center gap-3"
>
@@ -228,15 +222,15 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
-
-
+ ctx.onRemove([item.id])}
disabled={ctx.busy}
- className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
- aria-label={t("plex.playlist.removeItem", { title: item.title })}
+ label={t("plex.playlist.removeItem", { title: item.title })}
>
-
-
+
+
);
}
@@ -263,19 +257,22 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
-
-
+
ctx.toggleShow(block.showKey)}
- className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={open}
- aria-label={block.title}
+ label={block.title}
>
- {open ? : }
-
+ {open ? : }
+
{!dense && (
// A second, pointer-only target for the same toggle (its images are decorative): out of
// the tab order, so the keyboard does not stop on a button with no name.
-
ctx.toggleShow(block.showKey)}
className="flex shrink-0"
tabIndex={-1}
@@ -291,22 +288,22 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
style={{ marginLeft: i ? -14 : 0 }}
/>
))}
-
+
)}
-
ctx.toggleShow(block.showKey)} className="min-w-0 flex-1 text-left">
+ ctx.toggleShow(block.showKey)} className="min-w-0 flex-1">
{block.title}
{t("plex.playlist.episodes", { count: block.items.length })}
-
-
+ ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy}
- title={t("plex.playlist.removeShow")}
- className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
+ label={t("plex.playlist.removeShow")}
>
-
-
+
+
{open && (
@@ -322,29 +319,26 @@ function ShowGroup({ block, ctx }: { block: Extract
; ct
{dense && (
- ctx.toggleSeason(sKey)}
- className="grid h-5 w-5 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={sOpen}
+ label={seasonLabel(s)}
>
- {sOpen ? (
-
- ) : (
-
- )}
-
+ {sOpen ? : }
+
)}
{seasonLabel(s)} · {s.items.length}
- ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy}
- title={t("plex.playlist.removeSeason")}
- className="ml-1 rounded-sm p-0.5 text-muted hover:text-danger disabled:opacity-30"
+ label={t("plex.playlist.removeSeason")}
+ className="ml-1"
>
-
-
+
+
{sOpen && (
-
{
setName(q.data?.title ?? "");
setRenaming(true);
}}
- title={t("plex.playlist.rename")}
- className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-accent"
+ label={t("plex.playlist.rename")}
>
-
-
+
+
{items.length > 0 && (
-
onPlay(order[0]!, order)}
- className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90"
+ icon={ }
>
-
{t("plex.playlist.playAll")}
-
+
)}
-
-
-
+
+
+
{q.isError && !q.data ? (
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx
index bb3659f7..0a0c8a28 100644
--- a/frontend/src/components/Scheduler.tsx
+++ b/frontend/src/components/Scheduler.tsx
@@ -26,7 +26,8 @@ import { formatNumber, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -200,21 +201,12 @@ function JobRow({
}`}
/>
{t("scheduler.minutes")}
-
-
-
-
-
-
+
+
+
+
+
+
{!valid && (
{t("scheduler.range", { min: 1, max: 1440 })}
@@ -223,14 +215,14 @@ function JobRow({
) : (
- setDraft(String(job.interval_minutes))}
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
>
· {t("scheduler.everyMin", { count: job.interval_minutes })}
-
+
)}
@@ -261,14 +253,14 @@ function JobRow({
)}
-
-
-
+
+
);
@@ -516,17 +508,20 @@ export default function Scheduler() {
- }
onClick={() => runAllMut.mutate()}
disabled={runAllMut.isPending || data.paused}
- className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
-
{t("scheduler.runAll")}
-
+
- {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
@@ -537,21 +532,21 @@ export default function Scheduler() {
if (ok) purgeMut.mutate();
}}
disabled={purgeMut.isPending}
- className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
{t("scheduler.purgeDiscovery")}
-
+
- : }
onClick={() => pauseResume.mutate(data.paused)}
disabled={pauseResume.isPending}
- className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
- {data.paused ? : }
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
-
+
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index c69407f3..fff99313 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -24,6 +24,7 @@ import { START_ON_HOME } from "./navigationInit";
import { usePrefs } from "./prefsContext";
import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe";
+import { Pressable } from "./ui/Pressable";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
@@ -147,7 +148,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
{SCHEMES.map((s) => (
- setTheme({ ...theme, scheme: s.id as Scheme })}
aria-label={s.name}
aria-pressed={theme.scheme === s.id}
@@ -293,7 +294,7 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
/>
)}
-
notify({
level: "info",
@@ -302,10 +303,11 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
sound: true,
})
}
- className="mt-2 text-sm text-accent hover:underline"
+ variant="link"
+ className="mt-2 text-sm"
>
{t("settings.notifications.sendTest")}
-
+
);
}
@@ -439,14 +441,15 @@ function SignInMethods({ me }: { me: Me }) {
{t("settings.account.googleLink.connected")}
) : (
- {
window.location.href = withActiveAccount("/auth/link");
}}
- className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{t("settings.account.googleLink.connect")}
-
+
)}
)}
@@ -461,17 +464,19 @@ function SignInMethods({ me }: { me: Me }) {
: t("settings.account.password.unsetHint")}
- {
setOpen((o) => !o);
setErr(null);
}}
- className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{me.has_password
? t("settings.account.password.change")
: t("settings.account.password.set")}
-
+
{open && (