DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows; density stays open as B1. The lab keeps only the chosen A renderings as reference and adds batch B: density, the scheme set (measured accent hue gaps and light contrast), state layers, the page-chrome model, surface per role, the public watch page, the nav rail, the loading form and where the header + fade edge goes.
16 KiB
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: 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
- 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 isDataTable's sticky header. A flat opaque box where a glass surface belongs is a violation. - 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.
- 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 intoui/, and a screen composes primitives rather than restyling them. - 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.
- 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.revealclass), 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.
- 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-fgpartner 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 scheme set and the light-theme accent are decision B2. The input edge token is set under Controls (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; tabsrounded-lg. Buttons, icon buttons and form fieldsrounded-lg; toolbar triggers and search a pill (Controls). Surfaces per role: decision B5. - 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 (batch A; density Pending, B1)
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:mdin forms and dialog footers,smin toolbars and panel headers,xsinside a table row. Glass is aButtonvariant, not a hand-built glass button. The rung heights (standard 38/32/26 or compact 32/28/24) are decision B1. [HIG, Material] - Icon buttons (A2): two sizes, 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 themdheight; a toolbar search is a pill. 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/Buttonwith aSIZESrung. 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 isFilterSelect. 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 in the lab: B1 button density · B2 the scheme set (and the light-theme accent) · B3 state
layers (hover, pressed) · B4 the page-chrome model (a sticky band vs controls in the content, folding on
a short screen, the page h1) · B5 the surface and radius per role (card, dialog, menu, tooltip) · B6
the public watch page · B7 the nav rail · B8 the loading form (spinner vs skeleton) · B9 where
the header + fade edge goes.
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/Overlayor 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
ContextMenubuilt 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 isNavGrid/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/tabpanelroles. - 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 silentcatch. An error sits next to what failed and says how to recover; a field error is under its field and linked witharia-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: decision 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: noneso a finger drags instead of scrolling. [WCAG 2.5.7] - Motion: 150–300ms micro-interactions, transform/opacity only;
prefers-reduced-motionis 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-surfacebox 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 |