Files
siftlode/docs/DESIGN.md
T
peter 7d480e2ad0 feat(design): Matrix toned down to moss, text on an accent fill held to --accent-fg
The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the
moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed
white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use
--accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent.
2026-09-24 04:49:07 +02:00

33 KiB
Raw Blame History

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: 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.

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 (moss). 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]
  • 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 colours as text, the accent's hue gap). 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.
  • 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). 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, 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 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 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