# 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. 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 - 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. 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. [Nielsen #7, WCAG 1.4.10] - **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). 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: 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_ (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 "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 (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 labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser. Also in force: - An action button is `ui/Button` with a `SIZES` rung. A hand-rolled `