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.
281 lines
27 KiB
Markdown
281 lines
27 KiB
Markdown
# 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 `<button className="px-3 …">` 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 `<select>` (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 — _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.
|
||
- **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:
|
||
|
||
- 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), one item **Pending (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).
|
||
- **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
|
||
|
||
- 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 |
|
||
| 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 |
|