The queue row (previous, count, next, auto-advance, loop) wrapped onto three lines in the narrow side panel; glyph-only, it fits one. Mark watched joins the action icons as a toggle (check / double check). Every control keeps its full name and shortcut as its tooltip and accessible name; the docked bar keeps its labels. The related switch moves up to the toolbar rung (12px), and its second side reads just "Trending".
170 KiB
DESIGN.md — how Siftlode looks and behaves
This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern, and the interaction rules. Screens are measured against it. A pattern that has more than one look in the code is either a decision still open here (marked Pending) or a violation to fix.
Status: written in E28; every chapter is Decided and in force. The design lab (below) renders each rule's chosen form. A new question opens a Pending item here, with its variants in the lab, until the product owner settles it.
How this document changes
- A rule changes only through an explicit decision recorded in the decision log: what was decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the list for the next design round.
- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or Material. The tag is in brackets after the rule.
- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule that lives only in prose drifts. That is the finding that started this document.
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):
- 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: aButtonsize, anIconButtonsize, aChipsize, theSegmentedTogglerung, aSURFACErole. The lint holds the element, not the rung, so the rung is checked by eye and by measure. - 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.
- 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
SURFACErole (a class, which the measure cannot trace to a component) and the playlist table's own rows. - 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.
- 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. 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.
A copy-only change — translated text and nothing else: no markup, class, component or logic — runs step 4 alone: a screenshot of every state the changed text renders in, in Hungarian and in English (Hungarian runs about a fifth longer, so it is the one that wraps or clips). The key parity is the gate's.
The design lab
npm run dev, then open http://localhost:5173/design-lab. The lab renders each open decision's variants
with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and
backdrop. It is dev-only: main.tsx reaches it behind import.meta.env.DEV, and the build plugin
frontend/devOnlyGuard.ts fails a production build that would contain it. Once a decision is taken, the
losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living
style guide.
Principles
- Glass over a calm background is the look. Surfaces are translucent layers (
.glass,.glass-card,.glass-menu,.glass-media) over a per-scheme backdrop. Depth comes from the header + fade: a crisp sticky header with a short gradient just below it, so content dissolves as it slides under. The reference isDataTable's sticky header. A flat opaque box where a glass surface belongs is a violation. - Built for one known, daily user, on a desktop and a tablet. Density is welcome where a mouse is the pointer; on touch every target grows. Nothing is designed for phones.
- One primitive per pattern. Where a
components/ui/primitive exists and is used, the looks don't drift. The drift lives in hand-rolled markup. A new generic control goes intoui/, and a screen composes primitives rather than restyling them. - Measured, not eyeballed. Contrast, target sizes and layout at the contract sizes are measured in a real browser before a UI change is called done.
- Honest states. An error never renders as an empty state; loading, empty and error are three
different screens (
QueryState/StateMessage).
Device adaptation — Decided
- Supported: tablet portrait and landscape and every size above them (from 768px wide). Phones are not a target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size.
- Adapt by capability, never by user agent:
@media (pointer: coarse)and(hover: none)decide touch behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never literal pixel values scattered in call sites. [HIG, WCAG 1.4.10] - Touch never loses a function. Anything revealed on hover is visible at rest on
hover: none(the.revealclass), or it is removed on touch because the same action is reachable another way (.reveal-hover). [WCAG 1.4.13, Nielsen #6] - Targets: at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
overlapping hit areas fire the wrong control. It covers every control primitive, not only
Button(A3, under Controls). [HIG, WCAG 2.5.8] - Text inputs use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page when an input gets focus.
- 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/SidePanelopensui/Modal'sdrawer-startplacement (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
narrowHiddensteps aside, below 64rem (a 1024 window) one markedtabletHiddendoes, 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.
Tokens
- Colour: only semantic tokens (
--bg --surface --card --fg --muted --accent --accent-fg --danger --warn --success --info --border, each with a-fgpartner where text sits on it), 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--liveunder--live-fg(never--danger: "live" is not an error),--rating-mediaon a poster's scrim, and--media-chromefor the always-dark player's menus (owner decision F16). On that chrome--media-markis a marked place (the playhead, the crop frame, a Plex marker), the focus ring and what is chosen, and--media-dangeran error, as a light theme's accent and danger are dark on the chrome:media-marksre-points--focusand--chosenon the player (the Plex player, the YouTube stage) and on every surface painted--media-chrome(its menus, panels and bars), and a form there (media-scope) re-points--dangertoo. A chosen item there is white like the rest, marked by an amber tick (a track) or underline (a tab,ui/mediaChrome), never the accent as text; a choice filled with the accent keeps it, its own text holds on it. A fill that must be seen there — a native checkbox or slider (accent-chosen), the skip button's countdown — is the amber too; the Plex progress bar plays white with an amber head, so an amber marker ahead of the head never reads as already played.theme.contract.test.tsmeasures the red on the chrome and holds every class string that paints it to name one of the two. 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;--mutedkeeps ≥ 6:1 on the bare--bg, because it mostly sits on glass, which costs up to ~1.5 of the ratio. A tonal fill under text (a badge, the item count, the current rail row) is an opaquebg-tint*(its colour mixed into--surface), never a translucentbg-fg/8: the glass and what scrolls under it show through. [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,--warnand--success, and passes 4.5:1 both as text on--bgand under--accent-fgon 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.tsmeasures every scheme × mode inindex.cssagainst 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 greyoverlaygrain 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-cardhovers are violations (invisible on a light card). Selected stays the solid accent. The exceptions are 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); and a dark translucent button on a picture (themediavariant: a card's hover row, a rail's scroll arrows, Restart), whose hover is a 2px accent ring over a darker body, since a layer is lost in the picture. 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 —AdminUsersuser rows). - Tokens as utilities: the decided values live once in
index.css—--hover/--press,--edge-strong,--touch-target, and the utilitiesstate-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; tabsrounded-lg. Buttons, icon buttons and form fieldsrounded-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), plusunder, a surface's own layer under its content inside itsisolate. 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:mdin forms and dialog footers,smin toolbars and panel headers,xsinside a table row. Glass is aButtonvariant, not a hand-built glass button. [HIG, Material] - Density (B1): compact, by explicit height.
md2rem (32),sm1.75rem (28),xs1.5rem (24); icon buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger,smbutton 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(Textareafor several lines; a frame that also holds an icon or chips is painted withfieldClassaround anInnerInput). The edge iscolor-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,invalidinto the danger edge. A form field is r8 at themdheight (32); a toolbar search, a field in a popover or an inline rename issm(28), the toolbar search a pill; the page's one hero field islg(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 pagerxs(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);ColorInputis the browser's colour well, whichaccent-colordoes 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 staysform'sSwitch: 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 ONEui/MenuCheckin 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, pluslg40 for a page's single hero call to action, and nothing else); a glyph isIconButton(a requiredlabel); a card, row, tile or menu row whose content is the control isPressable; a filter or tag chip isChip; a sortable row's grip isDragHandle. On the always-dark player chrome bothButtonandIconButtonuse theoverlayvariant (white, a white layer), whatever the theme; the Plex control row is IconButton'splayerrung (36, a 20px glyph, round, 44 on touch). A pressed toggle ispressed: the solid accent on aButton, the accent glyph on anIconButton. A raw<button>(or a bareFocusSafeButton) outside the control primitives (eslintBUTTON_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).filteris a selectable option, solid accent whenpressed; 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");tokenis an applied narrowing, the accent tint, the whole chip removing it;tagis a user's tag, the solid accent;suggestis a suggestion not yet taken (dashed accent);ghostthe quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its own × isDismissibleChip: two sibling controls at most, never a button in a button. An overflowing chip lane measures a hidden mirror painted withchipClass, so the mirror cannot drift from the chips. - Unavailable with a reason stays focusable:
aria-disabledplus the reason in the label or tooltip, never nativedisabled, 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 isFilterSelect. Never a native<select>(lint-enforced). Its rung is the toolbar's, 28 withtext-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 (
IconButtons, their names as the tooltip); no words, at any width. - A single-select row of filter chips goes through
FilterChipGroup(its options areChips). - 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
.glassr16; a card or picker.glass-cardr12; a menu.glass-menur12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip.glass-menur8. 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); ashadow-*size beside a role would replace both and is a lint error too — a field that floats takesshadow-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
Badgewith 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'stone(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 throughpressed(the solid accent), never a barearia-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 isPageShell's toolbar slot (PageToolbar), above the one scroller; the floating header over it is its header slot (PageHeader: the page's own pills, the palette's Jump-to pill where a page has none,PageHeaderDefault). 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 isuseFilterFold+FilterFoldButton: the same row shown or hidden in place, never copied into a popover. The hiddenh1isPageShell'stitle; a view with its own visible title (a channel, a Plex show, a playlist) renders it asPageHeadingand the hidden one steps aside, so a page has exactly oneh1(a hand<h1>inside the shell is refused by a contract test). A section's caption isSectionLabel, anh2(anh3in a dialog), never a styleddiv. 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-belowutility (fade-below-panel/fade-above-panelon 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, nevervh/h-screen:100vhis the viewport with a mobile browser's toolbar retracted, so anh-screenshell runs its last rows under the toolbar (contract-tested). - Nav rail (B7): the
ui/NavRailprimitive, 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 theSkeletonprimitive), 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/Overlayor an anchored popover from the one popover engine. Escape closes the topmost layer only, and focus returns to the control that opened it. [WCAG 2.4.3, 2.1.2]
Action placement — Decided
Which actions are visible and which live in a menu.
- One menu, several triggers. An object's actions are ONE list. The visible "…" button, a right-click,
a long-press (touch) and Shift+F10 / the Menu key all open the same
ContextMenubuilt from that list. A second, hand-made menu for the same object is a violation. - Nothing is reachable only by a gesture. Every menu action also has a visible path: the "…" button, the page toolbar, or the object's detail view. A right-click is a shortcut, never the only door. [Nielsen #6, #7]
- What stays visible: the object's primary action (play or open) and any state the user needs to see (watched, saved, downloading). What goes into the menu: rare, secondary and destructive actions. Destructive ones keep their confirmation. [Nielsen #8, #5]
- On touch, a card shows fewer actions and a "…" button. The rest are in the menu.
- 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
useContextMenuper list; the row spreadstriggerProps(right-click, long-press, Shift+F10) and its "…" isui/MoreButtonwithbuttonProps(the same menu, under the button; a second press closes it). An object's list is one builder (useVideoActionsfor a video,usePlexCardActionsfor a Plex title, composed by their lists and by Home's rails); a row that opens a panel ("Add to playlist…", "Details") anchors it tomenu.anchor, the element the menu came from. "Copy title" / "Copy link" come fromuseCopyItemsand 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 (thelistDeletekey 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 itsusePlexCardActionslist plus the removal); removals take the same undo toast,j/kwalk 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 passesuseContextMenu({ keyOf })(its id), so the "…" still closes its own menu. A menu's groups areui/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/backgroundTargetdecides "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.tsholds 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, underPressableandButton). Chrome selects no text inside a<button>unless it isuser-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.tsmakes every newPressablechoose. - 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 inui/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'smediavariant), only on a card whose click starts playback. Every Plex picture plays, a show too: the episode in progress (Continue + Restart, offered only when an episode holds a resume point), else the first unwatched, else the first, which the click asks the server for (/show/{id}/next); its title and an ⓘ open the show's page, and one with no episodes opens there. 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); a show's adds the episode it would play ("Next: S6·E16 — Title"), asked as the panel opens. 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 nativetitletooltip. - 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
measureutility) 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 isNavGrid/keyboardNav. An open dialog or player silences the page's list keys, but a list inside the topmost one walks while the focus is in it (the player's related videos); the walk stays inside that layer, and its arrows there move the list, not the video. [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/tabpanelroles. - Feedback: a finished action is confirmed briefly (a toast that leaves by itself); anything that must be read or acted on is not only in a toast. Status changes are announced through a live region. [Nielsen #1, WCAG 2.2.1, 4.1.3]
- Errors: a failed write goes through
meta.failTitle/notifyActionError, never a silentcatch. An error sits next to what failed and says how to recover; a field error is under its field and linked witharia-describedby. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1] - Loading: anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one. Space is reserved for late content so the layout doesn't jump; 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: noneso 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-motionis 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
Buttonrungs (44 on touch); Previous / Next live in the panel (its transport row: glyphs only — Previous, the count, Next, auto-advance, loop on one line, each named in its tooltip — or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row under the video (stacked). Wide, the panel shows the description outright, clamped to three lines with a More (ClampedText; a Tab onto a link below the cut opens it), and collapses to a 2.75rem strip of close, expand and Previous / Next, kept shut as the queue steps on and open again on a new open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13] - Related videos (owner's picks A, A, a, a): on demand, never open by itself — the modal has no room to spare. Wide, a "Related" tab beside the description in the side panel; stacked, a button beside the ⓘ opens them in a drawer over the player. A row is the thumbnailed video row in its narrow form (a 6rem picture, the relative time only, the "…" its one control — every toggle is a row of that menu), with a check before the channel the user follows (named on hover). A click plays it in the same player (Back returns to the opened video), and the list stays the opened video's. Played, it is the item it now is (owner's pick D1 a): its title, its own watched / playlist / download controls, its progress kept, marked watched at its end — and its end does not step the queue. What the user watched or hid is never in it; a list read so far with nothing unseen says so above Show more. Over it, a two-way switch: on topic or trending (the Hungarian chart, labelled just "Trending"), kept on the account. Turned off on the instance (a daily limit of 0, owner's pick D2 a), there is no tab and no button; a refusal a retry cannot answer (the day's cap, turned off, the shared budget) offers no Retry.
- Clip mode (both players, owner's picks A + P1 + a): a scissors in the control row (Plex: the C5 row,
before the ⋯; YouTube: the actions beside Download, and the docked bar) or
Copens a clip panel IN the player;[/]mark the start / end at the playhead and open it when shut. The video being watched is the preview — no second stream. Plex: the panel takes the seek bar's and the row's place, and the picture stands above it. YouTube: it sits under the video (the card's fit counts it, so the video shrinks to make room) and the wide side panel shuts to its strip while the clip panel is open (owner; it opens again after, unless the user opened it meanwhile); maximised, the panel takes the docked bar's place; the browser's fullscreen steps back to maximised. The panel: an overview of the whole item (the selection and the zoomed window on it), a zoomed strip around the selection with the two ends (ui/RangeTrack: each end a slider — arrows step a tenth, Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the ends are white, every focus ring in the panel and the playhead the media amber--media-mark(the same in both themes; a light theme's accent and warn all but vanished there). A typed time that cannot be read stays in its field once offered (Enter, leaving it), marked invalid with a line under the fields' row saying how to type one (in the flow: the row below moves down rather than being covered); a time typed over a mark that then moves (a nudge, another segment) is dropped; one past the end shows where it landed (decimal point or comma). The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a moment short of it, and while a clip is marked the end of the item never closes the player or steps the queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over the picture (Plex: the info, the up-next card, the countdown); ⌫ under a dialog is the dialog's. The scissors the user can see gets the focus back (maximised: the docked bar's), else the player. The panel keeps its name, Loop and focus when the view is maximised or restored under it. Crop (owner's picks K1 + a): a Crop toggle in the panel puts a frame over the picture (ui/CropFrame: on the picture, not its bars; the outside dimmed; drag to move, edges and corners to resize, thirds while dragging; the arrows move it, a focused corner's resize it, Shift further; focus ring amber) with a shape — Free, 16:9, 9:16, 1:1 (the largest of it the picture holds) — and Whole picture; off, the clip is the whole picture. Escape turns the crop off before it closes the panel. A press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual "Ready to watch" notification. Limits are the server's: 1 s to 10 min (lib/clipRange, held todownloads/clip.pybyclipLimits.contract.test.ts; the "too long" message names the server's cap).
Dialogs — Decided (C4, built in S3f)
- Initial focus by purpose (
Modal's requiredinitialFocus, 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 markeddata-initial-focuswhen 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 takesautoFocus;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-contentmakes 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
ModalFooterfrom 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") throughui/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), throughformatUnit/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". NevertoFixedon 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 ofapp/collation.pyon 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), Napló (activity), Admin műveletek (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-surfacebox where a glass surface belongs. - A hand-rolled control that restyles what a primitive already does.
- Information or actions that exist only on hover, or only behind a right-click.
- A click handler on text that swallows the user's text selection.
- A colour literal, a px font size, a px-frozen box.
- An empty state shown for an error.
- 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) |
| 2026-10-08 | Captions on touch | A Tooltip opens for a mouse and a key's focus only, never for a finger |
A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by line |
On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The media variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) |
An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying next |
A show card on Discover had no Play while Home's had one; carrying next costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + C, [ ]; the panel in the player with the watched video as preview; overview + zoomed strip on ui/RangeTrack (ends are sliders); a toast + the Ready notification after Cut |
A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (ui/CropFrame, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping |
A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | --media-mark (amber) and --media-danger (light red), the same in both themes; media-marks points the focus ring (--focus) and the chosen mark (--chosen: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; media-scope (a form) also the errors at the red |
The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light --muted 15% toward --fg in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface |
Only the two failing components; the item count's number in --fg |
Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an --fg count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |