feat(design): design lab + DESIGN.md skeleton (E28 S2a)
- 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.
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
# 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 |
|
||||
@@ -32,6 +32,10 @@ For a fast frontend edit loop, run the Vite dev server with HMR against that API
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
||||
```
|
||||
|
||||
The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
|
||||
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
|
||||
(see [`DESIGN.md`](DESIGN.md)).
|
||||
|
||||
## The gate
|
||||
|
||||
A change is done when all of this is green. CI runs the same checks server-side
|
||||
|
||||
Reference in New Issue
Block a user