- docs/DESIGN.md: the written source of truth for the UI. Decided chapters (principles, device adaptation, action placement, text stays selectable, interaction rules, copy, anti-patterns, decision log); controls and surfaces are marked Pending until the owner picks from the lab. - /design-lab (dev only): renders each open decision's variants with the app's real CSS, themes and font scale. Batch A = six control decisions. - devOnlyGuard: a production build fails if anything under src/design-lab reaches the bundle (seen red with an unguarded import). - Size contract: tablet portrait + landscape and up, replacing the 1024x768 floor with its never-built "too small" message.
178 lines
12 KiB
Markdown
178 lines
12 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:** being written in E28. Chapters marked _Decided_ are in force. Chapters marked **Pending** are
|
||
open decisions, each 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. [HIG, WCAG 2.5.8] _Which controls this covers beyond
|
||
`Button` is decision A3._
|
||
- **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)**.
|
||
|
||
## 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 light-theme accent fix is decision B; the input edge token is decision A4._
|
||
- **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, inputs, triggers
|
||
and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._
|
||
- **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 — **Pending (batch A)**
|
||
|
||
Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button ·
|
||
**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger ·
|
||
**A6** checkbox, switch, slider.
|
||
|
||
Already 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 — **Pending (batch B)**
|
||
|
||
Open: the page-chrome model (a sticky toolbar vs controls in the content, collapsing, the page title), the
|
||
glass role per surface, the public watch page, the light-theme accent, menu items and popover surfaces, the
|
||
nav rail, and the loading form (spinner vs skeleton).
|
||
|
||
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. _Which actions stay
|
||
visible per object is part of batch C._
|
||
- **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. _Spinner vs skeleton: batch B._
|
||
- **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]
|
||
|
||
## 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 |
|