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() {
+
+
Decided · batch B, surfaces, chrome and colour
+
+ B1 (density) and B3 (state layers) are rendered inside the batch A references above.
+
+
+
);
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({