461 lines
70 KiB
Markdown
461 lines
70 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; 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](#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), and no palette class
|
||
(`text-amber-500`: lint-refused). A score's gold is `--rating` (theme-scoped, 4.5:1 on the page and on its
|
||
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
|
||
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
|
||
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
|
||
(owner decision F16). Black and white are the media scrims and their text. Four schemes × dark
|
||
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its
|
||
place with its own hue family. **Fjord** (Midnight's navy with a steel-blue accent) is a trial outside that rule: the owner
|
||
tries it app-wide, then it is folded into Midnight or removed. 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 ≥ 3:1 edge is the field
|
||
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
|
||
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
|
||
and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14).
|
||
- **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 and rating colours as text, the accent's
|
||
hue gap), and the media colours against what they sit on. 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. The one
|
||
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
|
||
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
|
||
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
|
||
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
|
||
badge read as broken; owner, E28 — `AdminUsers` user rows).
|
||
- **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`), plus `under`, a surface's own layer under its content inside its `isolate`.
|
||
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 "floats over something":
|
||
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB); a button
|
||
inside a pill field is round too, so the radii nest. A text
|
||
button on a picture is a pill (the card's Resume in the accent, From the start dark translucent, C5), and
|
||
the one 48px glyph is the Play in the middle of a not-started card (C5).
|
||
- **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, `ui/Input` (`Textarea` for several lines; a frame that also
|
||
holds an icon or chips is painted with `fieldClass` around an `InnerInput`). The edge is
|
||
`color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus
|
||
turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height
|
||
(32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill;
|
||
the page's one hero field is `lg` (40): the header search, a glass pill floating over the page (the
|
||
YouTube/Gmail shape), and the command palette's field;
|
||
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
|
||
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
|
||
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
|
||
choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and
|
||
lines up with the inputs. Open is the accent edge and the chevron turned over.
|
||
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`,
|
||
`CheckRow`, `Slider`); `ColorInput` is the browser's colour well, which `accent-color` does not reach (it
|
||
tints only checkboxes, radios, ranges and progress bars). The whole labelled row is the 44px target. Keyboard, screen reader and
|
||
forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native
|
||
one yet. A raw `<input>` or `<textarea>` outside those primitives is a lint error (a hidden file picker
|
||
passes). A checkable row that cannot hold an `<input>` — a menu row, a whole-row button whose role says
|
||
checkbox — draws the ONE `ui/MenuCheck` in its leading icon slot: a box filled with the accent for a set
|
||
(tags, playlists, a filter's values; a minus for a group only partly in), a bare tick for one choice (a
|
||
language, a sort, a priority step), an unticked choice keeping the slot empty so the labels line up.
|
||
- **In-place editing:** a field that replaces its trigger (a title rename, the priority number) hands the
|
||
focus back to that trigger when Enter or Escape closes it (`useReturnFocus`), never to `<body>`.
|
||
[WCAG 2.4.3]
|
||
|
||
Also in force:
|
||
|
||
- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
|
||
plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
|
||
required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
|
||
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
|
||
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
|
||
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
|
||
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` outside
|
||
`components/ui/` is a lint error. A text-less button must have an accessible name, and the type enforces
|
||
it. [WCAG 4.1.2]
|
||
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
|
||
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
|
||
chips too, and while their popover is open they show it as an open trigger does, the accent edge and
|
||
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
|
||
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
|
||
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
|
||
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
|
||
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
|
||
- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
|
||
never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
|
||
- 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 single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
|
||
- 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. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`,
|
||
`ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over
|
||
undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass
|
||
class is a lint error. A role's depth is its own shadow (the top highlight + the drop); a `shadow-*` size beside
|
||
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
|
||
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
|
||
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
|
||
A genre that filters the library is a chip; the same genre where it can't is a badge. A badge's `tone`
|
||
(neutral / accent / warn) is its own colour at 6%, measured at 4.5:1 in every scheme; a `<span>` drawn with a
|
||
round edge is a lint error (F29). A toggle button states itself through `pressed` (the solid accent), never a
|
||
bare `aria-pressed`, which the type refuses (F28).
|
||
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
|
||
`IconLink`: still an `<a>`, wearing the button's own face.
|
||
- **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]
|
||
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console
|
||
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below`
|
||
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it
|
||
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
|
||
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
|
||
view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading`
|
||
and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is
|
||
refused by a contract test). A section's caption is `SectionLabel`, an `h2` (an `h3` in a dialog),
|
||
never a styled `div`. The first Tab stop is "Skip to content" (`SkipLink`), which focuses the
|
||
scroller's `<main>`. [WCAG 2.4.1, 1.3.1]
|
||
- **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). Built as: a sticky element takes the
|
||
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
|
||
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
|
||
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
|
||
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned
|
||
footer (S3f).
|
||
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
|
||
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
|
||
- **Nav rail (B7):** the `ui/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. The switch is Settings → Appearance → "Labels in the side
|
||
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
|
||
unread count is part of the name, not only drawn.
|
||
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
|
||
"poster" | "list" | "table"`, built from the `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 "…".
|
||
- **Built as:** one `useContextMenu` per list; the row spreads `triggerProps` (right-click, long-press,
|
||
Shift+F10) and its "…" is `ui/MoreButton` with `buttonProps` (the same menu, under the button; a second
|
||
press closes it). An object's list is one builder (`useVideoActions` for a video, `usePlexCardActions`
|
||
for a Plex title, composed by their lists and by Home's rails); a row that opens a panel ("Add to playlist…", "Details") anchors it to
|
||
`menu.anchor`, the element the menu came from. "Copy title" / "Copy link" come from `useCopyItems` and
|
||
run inside the click (`inGesture`, Safari's clipboard rule). The card's hover row sits on its picture
|
||
(the duration steps aside while it shows); on touch the toggles are gone and a 44px "…" sits beside
|
||
the title (`.touch-only`). The hover chrome and what steps aside for it are one pair of class strings
|
||
(`ui/reveal.ts`), shared by the video and the Plex cards. A table or rail ROW shows its "…" at the
|
||
row's end on hover or focus and always on touch (`.reveal`). A playlist's rows take Remove from the
|
||
Delete key too (the `listDelete` key scope, registered only by a list that can remove) and answer it
|
||
with an undo toast whose Undo puts the row back at its index in one request; a playlist itself has
|
||
one list (`usePlaylistActions.items`) that its rail row and its page header's "…" both open. A
|
||
subscribed channel's row ends in its "…" (`useChannelActions`: open, pin, Tags, Priority, YouTube,
|
||
copy, full history, hide with an undo toast, unsubscribe behind its confirm), docked beside the name
|
||
in the card layout. Its Tags submenu lists the reader's tags A–Z, each a check that toggles in place
|
||
(the menu stays open for the next); its Priority submenu offers the fixed steps 10 · 5 · 3 · 2 · 1 · 0
|
||
(an off-scale current value shows ticked in its place), and a pick re-sorts the list at once — only
|
||
that row moves — with the pager and the focus (its "…") following it. The row's stepper still sets any
|
||
value and leaves the order until the next load. A Plex playlist's title, show group and season each open the page's one row menu
|
||
(a title's is its `usePlexCardActions` list plus the removal); removals take the same undo toast,
|
||
`j`/`k` walk the visible rows and Delete removes the walked one; its rename and delete sit in the
|
||
header's "…". A toast that carries an action lives at least 8 s, whatever the toast setting. A
|
||
download row keeps play (pause / resume / retry in the queue) and the save link, a library row also
|
||
Share (the link copy, ahead of save), and ends in its "…" (`useDownloadActions`: the editors,
|
||
managing the shares, re-download, the copies, cancel / delete / remove
|
||
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
|
||
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
|
||
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
|
||
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
|
||
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
|
||
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
|
||
groups are `ui/menuGroups`: a rule between
|
||
two non-empty groups only.
|
||
- **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 inside a click surface stays selectable: the card, row, person, channel or log line
|
||
that opens is `selectable` (`ui/FocusSafeButton`, under `Pressable` and `Button`). Chrome selects no
|
||
text inside a `<button>` unless it is `user-select: text` (measured), so the flag sets that and the
|
||
click guard below. A control's own label (a menu row, an option, a tab, a nav item, a switch) is not
|
||
a title and stays plain; `pressableSelectable.contract.test.ts` makes every new `Pressable` choose.
|
||
- 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]
|
||
A list whose rows have a "…" (a YouTube playlist, a Plex playlist: a block among the blocks, an
|
||
episode within its show) puts the four moves there (`ui/useOrderItems`); the saved views' editor
|
||
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
|
||
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
|
||
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
|
||
- **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 bars stay up while the pointer rests on them** (F22, owner); leaving starts a fresh 3 s. YouTube and
|
||
Plex Web do the same: before, the row faded out from under a mouse parked on Next.
|
||
- **Volume is the native `Slider`** (A6); the hand-drawn bar had no hover, focus, keyboard or screen-reader
|
||
state. A pointer drag gives the focus back, so Space and the arrows keep driving the player.
|
||
- **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 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||
| 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 |
|
||
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
||
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
||
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
||
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
||
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
||
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
||
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
||
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
||
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
||
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
||
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; on a phone the table drops "#" and Channel so the title keeps a width |
|
||
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
||
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|