A tabletHidden/narrowHidden column below its width, or any column in a card layout, took its header funnel with it: the rows were narrowed by a filter nobody could see. The cue is shown by the inverse of the column's own container query, so it agrees with the table at every width and text size. Wired into DataTable, the playlist table and the channel cards (E28 F2).
95 lines
12 KiB
Markdown
95 lines
12 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 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
|
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy") |
|
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `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`, `IconButton`'s and `Chip`'s sizes
|
|
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
|
|
and player rungs). 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** (the `touch-target` utility, 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 carry it too (and the 24 mouse floor): every one stands on its own. Text inside a
|
|
sentence is a plain `<a>`, which WCAG 2.5.8 exempts. On a coarse pointer the target wins over a
|
|
caller's own `min-h-6` / `min-w-6` (it is `!important` there), or a control carrying both stays 24.
|
|
|
|
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>` — or a bare `FocusSafeButton` — outside the control
|
|
primitives (eslint `BUTTON_PRIMITIVES`; not the rest of 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.
|