Files
siftlode/docs/DESIGN.md
T

408 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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), 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). Black and white are the media scrims and their text. Four schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its
place with its own hue family. **Fjord** (Midnight's navy with a steel-blue accent) is a trial outside that rule: the owner
tries it app-wide, then it is folded into Midnight or removed. 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 one
exception is 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).
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 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]
- **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 `<input>` or `<textarea>` outside those primitives is a lint error (a hidden file picker
passes).
- **In-place editing:** a field that replaces its trigger (a title rename, the priority number) hands the
focus back to that trigger when Enter or Escape closes it (`useReturnFocus`), never to `<body>`.
[WCAG 2.4.3]
Also in force:
- **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 `Chip`; 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; the Plex
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` outside
`components/ui/` is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
chips too, and while their popover is open they show it as an open trigger does, the accent edge and
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
- **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 `<select>` (lint-enforced).
- A single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1]
## Surfaces and page chrome — _Decided_ (batch B)
- **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. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`,
`ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over
undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass
class is a lint error. A role's depth is its own shadow (the top highlight + the drop); a `shadow-*` size beside
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
A genre that filters the library is a chip; the same genre where it can't is a badge. A badge's `tone`
(neutral / accent / warn) is its own colour at 6%, measured at 4.5:1 in every scheme; a `<span>` drawn with a
round edge is a lint error (F29). A toggle button states itself through `pressed` (the solid accent), never a
bare `aria-pressed`, which the type refuses (F28).
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
`IconLink`: still an `<a>`, wearing the button's own face.
- **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]
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below`
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading`
and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is
refused by a contract test). A section's caption is `SectionLabel`, an `h2` (an `h3` in a dialog),
never a styled `div`. The first Tab stop is "Skip to content" (`SkipLink`), which focuses the
scroller's `<main>`. [WCAG 2.4.1, 1.3.1]
- **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). Built as: a sticky element takes the
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned
footer (S3f).
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
- **Nav rail (B7):** the `ui/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. The switch is Settings → Appearance → "Labels in the side
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
unread count is part of the name, not only drawn.
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
"poster" | "list" | "table"`, built from the `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:
- 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.
- **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.
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 `<button>`. 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; 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]
## 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: 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 bars stay up while the pointer rests on them** (F22, owner); leaving starts a fresh 3 s. YouTube and
Plex Web do the same: before, the row faded out from under a mouse parked on Next.
- **Volume is the native `Slider`** (A6); the hand-drawn bar had no hover, focus, keyboard or screen-reader
state. A pointer drag gives the focus back, so Space and the arrows keep driving the player.
- **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]
## 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
- 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 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 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 |
| 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 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 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 |