Files
siftlode/frontend/src/components/ui/README.md
T
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00

7.3 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 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), 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 optimises for 1024x768 (tablet landscape) as the floor; below it we owe an honest message, above it correct rendering all the way up. 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 are 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-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. Two px box values are NOT deliberate and are worth converting when touched: Tooltip's max-w-[240px] (it does not widen with the slider while its text does, so the tooltip re-wraps at large text sizes) and form.tsx's left-[18px] toggle knob.
  • 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.
  • link is 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.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.