Files
siftlode/frontend/src/components/ui/README.md
T
peter d2e40dcffb feat(design): design lab + DESIGN.md skeleton (E28 S2a)
- 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.
2026-09-24 01:34:12 +02:00

91 lines
7.9 KiB
Markdown

# `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`](../../../../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` 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. 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.
- `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.