# 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:** 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 - 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. ## Conformance — the check before a UI change is done A screen is measured against this document, not against taste. Every change that renders something new runs this check before it is called done (E28 S3g): 1. **Primitives at a named rung.** Each control goes through its primitive (lint refuses the raw element, and a `
` with it), at a rung or role this document names: a `Button` size, an `IconButton` size, a `Chip` size, the `SegmentedToggle` rung, a `SURFACE` role. The lint holds the element, not the rung, so the rung is checked by eye and by measure. 2. **The pattern's chapter, re-read.** The change matches the chapter of every pattern it touches, or it opens a **Pending** item and a log row. It never quietly adds a second look. 3. **Measured.** On the changed pages, the rendered elements are grouped by kind (button, icon button, chip, field, tab, segment, menu row, surface) and by their computed look (radius, height, type, fill, edge). Each distinct look maps to a rung or role named here; a look that maps to none is a violation or a new decision. The S3g baseline across the 15 pages at 1920 × 1080: every button, icon button, chip, field, tab, segment and menu row is drawn by its primitive. What the measure still counts as hand-drawn is by design: text links (no primitive), surfaces painted by their `SURFACE` role (a class, which the measure cannot trace to a component) and the playlist table's own rows. 4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's. 5. **Judged as a whole** (E30 S6). For each screenshot, one written line on its composition: where the empty space is, what hugs one side, what the eye lands on first. Then the screen at 125% and 133% browser zoom and at the largest text size. All are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6). A rule that holds is not the same as a screen that works: the person page passed every rule above with half its panel empty. A **copy-only** change — translated text and nothing else: no markup, class, component or logic — runs step 4 alone: a screenshot of every state the changed text renders in, in Hungarian and in English (Hungarian runs about a fifth longer, so it is the one that wraps or clips). The key parity is the gate's. ## 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. 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. - **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. Built in S3f3: `ui/SidePanel` opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone below 1024, not honoured. [Nielsen #7, WCAG 1.4.10] - **A row answers to its own width.** A list row that must fit a narrow column (the download row, the playlist table, the Channels tag row) stacks by a container query on its own box, not by the window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its end, never a flex row that breaks its pieces apart. - **A table gives its secondary columns back.** By the table's own width, as a row does: below 42rem a column marked `narrowHidden` steps aside, below 64rem (a 1024 window) one marked `tabletHidden` does, so the name keeps its room. The Channels table drops Prio, About and Length there (at 1024 the name had 60 px); the row's "…" still sets the priority. - **Players on touch (C3):** see [Players](#players). ## 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), and no palette class (`text-amber-500`: lint-refused). A score's gold is `--rating` (theme-scoped, 4.5:1 on the page and on its own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes, because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus (owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a Plex marker), the focus ring and what is chosen, and `--media-danger` an error, as a light theme's accent and danger are dark on the chrome: `media-marks` re-points `--focus` and `--chosen` on the player (the Plex player, the YouTube stage) and on every surface painted `--media-chrome` (its menus, panels and bars), and a form there (`media-scope`) re-points `--danger` too. A chosen item there is white like the rest, marked by an amber tick (a track) or underline (a tab, `ui/mediaChrome`), never the accent as text; a choice filled with the accent keeps it, its own text holds on it. A fill that must be seen there — a native checkbox or slider (`accent-chosen`), the skip button's countdown — is the amber too; the Plex progress bar plays white with an amber head, so an amber marker ahead of the head never reads as already played. `theme.contract.test.ts` measures the red on the chrome and holds every class string that paints it to name one of the two. Black and white are the media scrims and their text. Five schemes × dark and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept exception, the calm twin of the vivid Midnight. A new scheme still needs 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 ≥ 3:1 edge is the field family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it, and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14). - **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] - **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 and rating colours as text, the accent's hue gap), and the media colours against what they sit on. 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. The exceptions are an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge (`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14); and a dark translucent button on a picture (the `media` variant: a card's hover row, a rail's scroll arrows, Restart), whose hover is a 2px accent ring over a darker body, since a layer is lost in the picture. A card that is one row with actions answers as a whole: the layer is the card's, under its badges and buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first badge read as broken; owner, E28 — `AdminUsers` user rows). - **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 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`), plus `under`, a surface's own layer under its content inside its `isolate`. No ad-hoc z-index. [maintainability] ## 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. [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 "floats over something": on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB); a button inside a pill field is round too, so the radii nest. A text button on a picture is a pill (the card's Continue in the accent, Restart 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] - **Input (A4):** one bordered input primitive, `ui/Input` (`Textarea` for several lines; a frame that also holds an icon or chips is painted with `fieldClass` around an `InnerInput`). The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height (32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill; the page's one hero field is `lg` (40): the header search, a glass pill floating over the page (the YouTube/Gmail shape), and the command palette's field; a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type (`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11] - **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and lines up with the inputs. Open is the accent edge and the chevron turned over. - **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`, `CheckRow`, `Slider`); `ColorInput` is the browser's colour well, which `accent-color` does not reach (it tints only checkboxes, radios, ranges and progress bars). The whole labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native one yet. A raw `` or `