- docs/DESIGN.md: the written source of truth for the UI. Decided chapters (principles, device adaptation, action placement, text stays selectable, interaction rules, copy, anti-patterns, decision log); controls and surfaces are marked Pending until the owner picks from the lab. - /design-lab (dev only): renders each open decision's variants with the app's real CSS, themes and font scale. Batch A = six control decisions. - devOnlyGuard: a production build fails if anything under src/design-lab reaches the bundle (seen red with an unguarded import). - Size contract: tablet portrait + landscape and up, replacing the 1024x768 floor with its never-built "too small" message.
7.9 KiB
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, 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'sSIZESare the rungs, derived from what the app actually used. 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-scaleon the root font-size) scales boxes and padding with the label. Atext-[Npx]font size is an eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements —VirtualGrid'sminCol/colMaxWidthandgridColumns'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.5is 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. linkis excluded from the minimum: it is text inside a sentence.
Controls outside this directory (chips, list rows, the player's overlay buttons) are not covered yet -- they are the next thing to bring under the contract, not evidence that there isn't one.
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.tsruns 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.tscounts 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.