Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.
The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.
The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
714 lines
126 KiB
Markdown
714 lines
126 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.
|
||
|
||
## Conformance — the check before a UI change is done
|
||
|
||
A screen is measured against this document, not against taste. Every change that renders something new
|
||
runs this check before it is called done (E28 S3g):
|
||
|
||
1. **Primitives at a named rung.** Each control goes through its primitive (lint refuses the raw element,
|
||
and a `<div role="button">` with it), at a rung or role this document names: a `Button` size, an
|
||
`IconButton` size, a `Chip` size, the `SegmentedToggle` rung, a `SURFACE` role. The lint holds the
|
||
element, not the rung, so the rung is checked by eye and by measure.
|
||
2. **The pattern's chapter, re-read.** The change matches the chapter of every pattern it touches, or it
|
||
opens a **Pending** item and a log row. It never quietly adds a second look.
|
||
3. **Measured.** On the changed pages, the rendered elements are grouped by kind (button, icon button,
|
||
chip, field, tab, segment, menu row, surface) and by their computed look (radius, height, type, fill,
|
||
edge). Each distinct look maps to a rung or role named here; a look that maps to none is a violation
|
||
or a new decision. The S3g baseline across the 15 pages at 1920 × 1080: every button, icon button,
|
||
chip, field, tab, segment and menu row is drawn by its primitive. What the measure still counts as
|
||
hand-drawn is by design: text links (no primitive), surfaces painted by their `SURFACE` role (a class,
|
||
which the measure cannot trace to a component) and the playlist table's own rows.
|
||
4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a
|
||
narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's.
|
||
5. **Judged as a whole** (E30 S6). For each screenshot, one written line on its composition: where the empty
|
||
space is, what hugs one side, what the eye lands on first. Then the screen at 125% and 133% browser
|
||
zoom and at the largest text size. All are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6).
|
||
A rule that holds is not the same as a screen that works: the person page passed every rule above with
|
||
half its panel empty.
|
||
|
||
## 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. Built in S3f3: `ui/SidePanel`
|
||
opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll
|
||
lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone
|
||
below 1024, not honoured. [Nielsen #7, WCAG 1.4.10]
|
||
- **A row answers to its own width.** A list row that must fit a narrow column (the download row, the
|
||
playlist table, the Channels tag row) stacks by a container query on its own box, not by the
|
||
window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A
|
||
line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its
|
||
end, never a flex row that breaks its pieces apart.
|
||
- **A table gives its secondary columns back.** By the table's own width, as a row does: below 42rem
|
||
a column marked `narrowHidden` steps aside, below 64rem (a 1024 window) one marked `tabletHidden`
|
||
does, so the name keeps its room. The Channels table drops Prio, About and Length there (at 1024 the
|
||
name had 60 px); the row's "…" still sets the priority.
|
||
- **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. Five schemes × dark
|
||
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
|
||
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
|
||
exception, the calm twin of the vivid Midnight. A new scheme still needs 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 ≥ 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 Continue in the accent, Restart 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>` (or a bare
|
||
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) 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). Its rung is the toolbar's, 28 with `text-xs`, so it lines up
|
||
with the triggers and the pager's buttons; the 24 rung with the smallest type is only a side panel's
|
||
captioned match toggle (S3g: the old small default had put a pager and a toolbar count on 10px type).
|
||
- **A multi-value filter's any/all switch** (tags, topics, languages, genres, collections, actors) is
|
||
`ui/MatchToggle`, on the panel right above its picker, from two picks on — never in the picker's
|
||
popover, where a closed picker hid that "all" was on. One wording: Match · Any / All. [Nielsen #1]
|
||
- **The pager** is four glyphs — first, previous, next, last — round the page box (`IconButton`s, their
|
||
names as the tooltip); no words, at any width.
|
||
- 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]
|
||
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
||
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed
|
||
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
|
||
|
||
## 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 shows only once something is under it, never over a list at rest. 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. Built (`ui/PageMenu`; PageShell listens, `lib/backgroundTarget` decides
|
||
"empty") on five pages: the **feed** (sort, reshuffle, view, watch state, content types, source, clear
|
||
filters), the **Plex library grid** (sort, watch state, clear filters), the **channel manager** (layout,
|
||
the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions from YouTube), a
|
||
**playlist** (play all, group by channel, reset view, save order, undo / redo, and the list's own "…")
|
||
and the **notification center** (mark all read). Never a destructive row (delete, revert, clear
|
||
everything) and never a long quota run (backfill): a stray right-click must not be one click from those.
|
||
The tab-only pages keep the browser's menu; `pageMenuScope.contract.test.ts` holds the list.
|
||
|
||
### 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.
|
||
- A right-click on selected words opens the browser's own menu (Copy, Search, Translate), not the
|
||
object's; a right-click beside the selection still opens the object's menu (`ui/useContextMenu`).
|
||
- On touch the long-press belongs to the context menu, so the object's menu offers **Copy title** and
|
||
**Copy link**.
|
||
|
||
## Media card — _Decided_ (S3h)
|
||
|
||
A film, a show, an episode, a video or a pinned channel is ONE card wherever it appears: the Plex grid,
|
||
the feed, Home's shelves and hero, the Plex rails, the media-info page's collection strip. A place may
|
||
pick a density step; it never changes the anatomy or loses an action. [Nielsen #4]
|
||
|
||
- **Anatomy (the owner's pick C).** One frame: the picture, the title, the meta line pinned to the
|
||
frame's bottom edge, so a one-line title leaves no hole above the meta. What is ON the picture: the
|
||
length (a film's runtime, a show's season count, a video's duration) as a dark badge in the corner,
|
||
the watched mark, the progress bar along the bottom. Built as `ui/MediaCard` + the classes in
|
||
`ui/mediaCardStyles` (`MEDIA_PICTURE`, `MEDIA_IMG`, `MEDIA_BADGE`, `MEDIA_HOVER_LAYER`, `MEDIA_BAR`,
|
||
`mediaTitleClass`, `mediaPictureBox`).
|
||
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
||
one round media Play (the button family's `media` variant), only on a card whose click starts
|
||
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
|
||
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
||
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
||
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
||
ellipsis, over ONE meta line (Plex: year · ★; a video: the channel; a tile: its count), so every card on
|
||
the strip is the same height. A Home shelf slot is 232px: at 200 the Play and the hover row
|
||
overlapped by 2px, at 232 they stand 6px apart; a landscape shelf picture takes the smaller Play.
|
||
- **A mixed shelf is landscape.** Beside videos a Plex title shows its wide art, not its poster; a
|
||
rail's slot is a grid cell, so each card fills the height the strip stretches to. A carousel turns
|
||
the cards' content-visibility off (`mediaRail.contract.test.ts`): an off-screen card's reserved height
|
||
had become the strip's.
|
||
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
||
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
||
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
||
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
||
native `title` tooltip.
|
||
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
||
whole menu on right-click, long-press and a touch "…" in its button row. In a wide window it stands
|
||
three quarters wide and centred, with even padding; its text is one centred stack — kicker, title,
|
||
subtitle (dropped when it is only the year), one fact line, a summary only when there is one.
|
||
- **Home's shelf tiles act like their object.** A pinned channel or playlist: Play when it has a next
|
||
video, ↗ to open it, "…" with an Open row. A show carries its next episode: the Play, the watched
|
||
toggle and add act on that episode ("Next: S6·E16" is its meta line), and an ⓘ opens the show. A
|
||
channel's Home menu is play, open and the shelf's own rows; its tags, priority, hide and unsubscribe
|
||
stay on the Channels page.
|
||
- **The compact list row (the owner's pick B).** The title leads; a faint "…" is always at the row's end;
|
||
on hover or focus the quick toggles float in beside it over the date, which steps aside (the Gmail
|
||
row). On touch only the "…".
|
||
- **The media-info page (layout B).** Under the hero a main column — the collection strips (the same
|
||
card), then the crew, then the cast — beside a 20rem sidebar of facts (details, keywords). Every
|
||
section is the reader's to hide; a column with nothing in it is not drawn and the other takes the
|
||
width. A show's page uses the same meta row: a value that filters is a chip, a plain fact is flat
|
||
text, spaced by the gap, no "·".
|
||
- **Browse tiles** (genres, playlists, collections) answer the pointer as a card does: the edge takes
|
||
the accent, the shadow lifts and the art zooms.
|
||
- **A missing picture says what the item is.** No picture at all (a link that failed before its
|
||
details, a poster that never came) shows a quiet glyph — a film, a note, a file — never an empty box,
|
||
which reads as "still loading". A download with no name yet is titled by its short link, once: no
|
||
channel line, no second link line.
|
||
- **Reading measure.** Prose (a synopsis, a biography, a channel's description) is capped at ~65
|
||
characters a line (the `measure` utility) wherever its container can be wider. [WCAG 1.4.8]
|
||
|
||
## Composition and the reader — _Decided_ (E30 S6)
|
||
|
||
The rules above say how each piece looks; these say whether the screen works as a whole, for the one
|
||
reader it is built for. Found when the person page passed every rule with its biography in the left half
|
||
of a full-width panel and nothing on the right.
|
||
|
||
- **The space is used.** A narrow element in a wide container (prose at `measure`, a form, a short list)
|
||
comes with a decision about the rest of the width: a second column with related content (the person
|
||
page: facts and bio left, "Known for" right), a container sized to its content, or a layout that stacks.
|
||
A band of nothing wider than about a third of a panel, beside content that stops short, is a finding.
|
||
[Material layout, Gestalt proximity]
|
||
- **One focal point, then an order.** A screen has one thing the eye lands on first (the page heading, the
|
||
hero, the player), then the sections, then the metadata. A heading level keeps one look across screens;
|
||
a second focal point competing with the first is a finding. [Nielsen #8, HIG hierarchy]
|
||
- **Worst real data, not the demo row.** A screen is judged with the longest title, the Hungarian label,
|
||
zero items, the largest list, a missing picture and a missing field. None of them clips, overlaps or
|
||
leaves a hole where a value was. [Nielsen #9, WCAG 1.4.10]
|
||
- **Where am I.** The browser tab names the place and the object in it ("Nicolas Cage · Plex · Siftlode"),
|
||
the rail marks the place, and Back returns to the same view at the same scroll position with focus
|
||
where it was. [Nielsen #1, WCAG 2.4.2]
|
||
- **A click answers at once.** A control shows its pressed or changed state within ~100ms (an optimistic
|
||
update, a spinner in the control); what a screen opens with is the app's own data — an outside service
|
||
(TMDB, YouTube) is fetched ahead of time by a job, not on the click, so a page never waits on it.
|
||
Late content keeps its place (B8). [Nielsen #1, RAIL]
|
||
- **Zoom and text size are first-class.** The owner reads with the browser zoomed and a large text size.
|
||
Every screen holds at 125% and 133% browser zoom on a 1080p desktop window (what the owner uses: the
|
||
layout of a 1536 × 729 and a 1444 × 685 viewport) and at the largest Settings text size at 1024 wide:
|
||
nothing clipped or overlapping, no sideways page scroll, every control reachable and at
|
||
least its rung, text wrapping rather than cut. Measured, not assumed, on every UI change. [WCAG 1.4.4,
|
||
1.4.10, 1.4.12]
|
||
|
||
## 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. A list item the keyboard lands on rings 2 px
|
||
outside itself where it has a gap round it (a card); a table row, a log line or a row flush in a
|
||
clipping card rings inside itself, following its corners, since outside the ring ran under the table
|
||
head or was clipped away. [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, built in S3f)
|
||
|
||
- **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. The volume slider is
|
||
mouse only: a tablet's volume is its buttons.
|
||
- **Restarting lives on the media card** (C5): not started → Play; started → **Continue** (primary) +
|
||
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
|
||
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
|
||
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
|
||
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
|
||
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
|
||
"Play from the beginning", and its picture carries the cards' progress bar.
|
||
- **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, built in S3f)
|
||
|
||
- **Initial focus by purpose** (`Modal`'s required `initialFocus`, so no dialog inherits "the first
|
||
focusable", which is always the ✕): `field` — a form opens on its first field: the body's first
|
||
control, or the one marked `data-initial-focus` when a control above the form is not one of its
|
||
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
|
||
there when it arrives, as long as the focus still rests on the ✕;
|
||
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
|
||
browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the
|
||
panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a
|
||
focused ✕ closed the film. [WAI-ARIA APG]
|
||
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
|
||
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
|
||
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
|
||
slide it over a fixed dialog). A fade shows only while content is under it (the body's scroll timeline):
|
||
a dialog that fits draws none. [Nielsen #3, WCAG 2.4.11]
|
||
- **The footer is the dialog's commit row** — Cancel / Save, Start, Retry / Close, a confirm's choices —
|
||
placed with `ModalFooter` from any depth of the body. A section's own buttons (a sub-form inside a
|
||
manager, a list row's Delete) stay in the body; a manager with no commit row has no footer.
|
||
|
||
## Copy — _Decided_ (S3g)
|
||
|
||
- 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. Hungarian speaks to the user as "te".
|
||
- **One name per feature.** The nav rail's label is the feature's name; page titles, menus, shortcuts,
|
||
settings tabs and onboarding use the same words. The rail may shorten them ("Config" for
|
||
"Configuration", "Listák" for "Lejátszási listák"), never swap in another word. [Nielsen #4]
|
||
- **One verb per meaning.** Delete / Törlés: gone for good. Remove / Eltávolítás: out of a list, the
|
||
thing itself survives. Dismiss / Elrejtés: a notice leaves the view. Discard / Elvetés: unsaved edits
|
||
are thrown away. A confirm uses its action's verb. [Nielsen #4]
|
||
- **Sentence case.** Capitals only for brand names (YouTube, Plex, Google) and the app's places used as
|
||
places ("Hide from Feed", "Megnézés a Könyvtárban"). [Material, HIG]
|
||
- **"…" when the command asks first.** A label ends in "…" when it opens a dialog, form or picker before
|
||
anything happens ("Edit tags…"); not on a submenu (its chevron says it) or an immediate action. Progress
|
||
text ("Loading…") keeps its own. [HIG, Windows]
|
||
- **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync
|
||
subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose.
|
||
- **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian.
|
||
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/locale-format`): recency in a list
|
||
is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover;
|
||
a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" /
|
||
"2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder.
|
||
- **Units.** A figure with a unit (a length, a countdown, a range of days) is the browser's short form
|
||
(`Intl`), through `formatUnit` / `formatRuntime`: "90 min", "2 hr 13 min" / "90 p", "2 ó 13 p"; a fixed
|
||
label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h",
|
||
"7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the
|
||
unit is a word. A clock-style length on a picture stays "2:34:11".
|
||
- **Numbers** in the UI language, from `lib/format.ts` (same lint): the decimal mark and grouping are the
|
||
language's ("8.3" / "8,3", "1,234" / "1 234"); a big count is compact ("1.2K" / "1,2 E"); a size keeps
|
||
its symbol ("5.0 GB" / "5,0 GB"), as do a stream's (Mbps) and "%" / "px". Never `toFixed` on screen.
|
||
- **Order.** An A→Z list is in the UI language's alphabet ("Ö" after "O", "Cs" after "Cu" in Hungarian),
|
||
by the name the reader SEES: `compareText` (`lib/collate.ts`) on the client, the ICU collation of
|
||
`app/collation.py` on the server, so both sort alike.
|
||
- **Hungarian words, not English ones** (E30 D3). Kept as Hungarian users say them: Feed, Shorts, Admin,
|
||
Token, Port, and technical tokens (log levels, ffmpeg presets, remux). Everything else is Hungarian:
|
||
közvetítés (stream), Prioritás, Feladat (job), Eseménynapló (audit), Demó, Automatikus, Infó,
|
||
Felület / Szerver (frontend / backend), videók behúzása (backfill), átkódolás (transcode), e-mail.
|
||
A longer Hungarian word gets its room — a column is sized to it, a pill truncates — never clipped.
|
||
- 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.
|
||
- Half a panel left empty beside content that stops short.
|
||
- A page that waits on an outside service when the user opens it.
|
||
- A screen checked only at 100% zoom and the default text size.
|
||
|
||
## 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; a narrow 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-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
||
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
||
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
||
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
||
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
||
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
||
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
||
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
||
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
||
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
||
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
||
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
||
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
||
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
||
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
||
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||
| 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 |
|
||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
||
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
||
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
||
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|