Files
siftlode/frontend/src/components/ui
peter 86f467bd65 feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.

Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
2026-09-24 05:25:55 +02:00
..

components/ui/ — the platform layer

Everything in this directory is a platform component: generic, reusable, and domain-free. Everything in components/ above it is a module component: it knows about videos, channels, playlists, Plex or downloads.

That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among ~160 feature components, and the platform was invisible — several of them literally describe themselves as "the one place", "shared", "canonical", "app-wide", and were still routinely bypassed by a hand-rolled <button> or a re-implemented popover. A layer you cannot see is a layer nobody uses.

The rule

A component belongs here iff its props and imports name no domain concept. It may import:

  • sibling primitives in this directory,
  • generic helpers from lib/ (layerStack, history, columnSort, useScrollFade, …),
  • i18n, React, and third-party UI libraries.

It may not import a feature component (../Feed) or a domain API slice (lib/api/*). This is enforced by no-restricted-imports in eslint.config.js — not by this README, which is only the explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module supply it. The same goes for its words: a primitive translates only from the platform i18n namespaces (common, errors, datatable, keys, palette, sidebar, notifications, time — uiI18n.contract.test.ts); what a value means where it is used arrives as a prop (PriorityStepper's hint).

The direction is one-way from below, too: lib/ may not import from components/ — this directory included, not even a type (same lint file). A contract both layers need lives in lib/: the Column / FilterMap shape and filterRows are lib/tableColumns, which tableHeader and DataTable import.

Counter-examples, deliberately NOT here: PosterTile (knows about Plex montage posters), KeyGate (secure-messaging key setup), plexCards, downloadRow, channelColumns — all shared, none domain-free.

What lives here

area components
overlays & anchoring AnchoredPopover + anchoredPopoverHooks (the popover engine: arrows, typeahead, Escape, anchor-flip), ActionMenu, ContextMenu + useContextMenu (the SAME menu, opened by right-click / long-press / Shift+F10), Modal, Overlay, SidePanel, Tooltip, Toaster (+ toastLevels, overlayOrder; lib/toastDock docks one control under the stack, in every host), CommandPalette, KeyboardHelp
actions & inputs Button, IconButton, Pressable, DragHandle (one per button family, docs/DESIGN.md), form, DraftSaveBar, Combobox, FilterSelect, SegmentedToggle, SortControl, ColumnSortControl, PriorityStepper, UndoToolbar
data display DataTable, tableHeader + tableAlign, Pager + pagerContent, VirtualGrid + gridColumns (the column/width maths, incl. the ultrawide maxCols cap), readAhead, Carousel, NavGrid (a plain grid, list or table body the keyboard walks — DataTable, VirtualGrid and Carousel take keyboardNav), Avatar, CountBadge, ItemCount, FilterChipGroup
page & panel structure PageShell + pageToolbarSlot, PageScroller, PanelGroup, PanelGroups, Tabs, BackToTop
state & messaging QueryState, Banner, InlineNotice

The size contract (E10 S1c)

The app supports tablet portrait and landscape and every size above them (from 768px wide; phones are not a target — see docs/DESIGN.md § Device adaptation). Measure a UI change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the ~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:

  • Geometry is a ladder, not a free choice. Button's SIZES and IconButton's sizes are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll padding on a primitive.
  • Type, padding and box geometry are rem-derived, so the Settings text-size slider (--font-scale on the root font-size) scales boxes and padding with the label. A text-[Npx] font size is an eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements — VirtualGrid's minCol / colMaxWidth and gridColumns' GRID_GAP — which are compared against a container's own pixel width rather than sized against text. A px-frozen BOX utility (w-[Npx], max-w-[Npx], …) is an eslint error too, since the 2026-09 sweep: use the spacing scale (w-37.5 is 150px at the default size and grows with the slider).
  • Minimum target: 2.75rem, on coarse pointers only (.touch-target, index.css). A 32px icon button is fine with a mouse and not with a thumb, so the box grows where there is no precise pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas on neighbouring icon buttons would overlap, and an overlap fires the wrong button.
  • The link variants are excluded from the minimum: they are text inside a sentence.

E28 S3b brings every button in the app onto these primitives (the chips and the player's overlay buttons last), after which a raw <button> outside this directory is a lint error.

Keeping it honest

Two of this directory's promises are load-bearing enough that prose was not enough, so each has a test rather than a paragraph:

  • the import boundary above → lib/uiBoundary.contract.test.ts runs eslint over fixture imports. It exists because the first cut of that rule wrote its regexes in plain "..." literals, where \. collapses to the any-char . — it read like a boundary and let the two commonest violations through, with a green gate saying nothing (E10 review round 1).
  • the chip lanes' clip → lib/chipLane.contract.test.ts counts measurers against lanes per file.

If you add a rule here, ask what would fail if it stopped being true — and if the answer is "nothing", write the test instead of the sentence.

Adding one

Move a component in when a second module needs it and it is domain-free — not on speculation. Adding a variant to an existing primitive beats adding a primitive; a second component that paints the same thing differently is how the last inconsistency happened.