Files
siftlode/frontend/src/components/ui/README.md
T
peter 3553eac8c4 feat(settings): upload a Google Takeout export in Settings › Account
A "YouTube history" section under YouTube access: the watch history and
the Watch later csv are dropped or picked (one request each, each file's
outcome said on its own), and the section then says what the import did
- only the counts it produced - or that it is running, failed or was cut
off by a restart. While it runs it asks again until it ends; a failed
re-read shows the failure, not the previous answer.

The drop zone the Files upload had inline is now ui/DropZone, used by
both. The e2e seed clears the account's kept Takeout videos.
2026-10-23 18:51:05 +02:00

21 KiB

components/ui/ — the platform layer

Everything in this directory is a platform component: generic, reusable, and domain-free. Everything above it — features/, modules/ and shell/ under src/ — 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 anything from features/, modules/ or shell/, or a domain API slice (lib/api/*). This is enforced in eslint.config.js (no-restricted-imports and local/layers) — 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), 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, MembershipMenu (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), 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, ConfirmProvider + confirmContext (useConfirm / useChoice: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one useChoice, never two chained confirms), dialogFocus (the focus half of the dialog contract: Modal's, and the wizard's own shell), menuGroups (one menu from its groups, a rule only between non-empty ones), pageMenuSlot + pageMenuItems (a page's background menu, mirroring its own toolbar)
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, RenameField (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), DropZone (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), Meter (how full something is against a limit — one danger line for every place showing the same measure), TimecodeField + useTimecodeField / TimecodeError (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), NativeControls, MenuCheck (the drawn check of a menu row or a whole-row checkbox button; its tick is the chosen colour, which media-marks turns amber on the player chrome), mediaChrome (MEDIA_CHOSEN, a chosen tab or toggle on the chrome), 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; NavRailGroup folds a set of rare pages into one row with a panel), form, DraftSaveBar, Combobox, FilterSelect, SegmentedToggle, SortControl, ColumnSortControl, PriorityStepper, UndoToolbar, ViewSwitcher (a list's display modes behind one toolbar icon), copyItems (useCopyItems: the "Copy title" / "Copy link" rows every object's menu ends with) + useCopyFeedback (a Copy button's "Copied" moment), MatchToggle (a multi-value filter's any/all switch), LanguageSwitcher (presentational: the caller decides what a change does), sortableA11y (what a screen reader hears during a keyboard reorder)
data display DataTable (+ progressiveRows: a long page mounts in steps), 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), RemoteImage (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare <img> is linted out), Avatar, CountBadge, ItemCount, FilterChipGroup, RelativeTime ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), ClampedText (a long text clamped to a few lines; More / Less only when it is cut), StatTiles + StatTile / StatBars (number tiles, a breakdown as horizontal bars), RangeTrack + trackPosition + RulerTicks (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), CropFrame (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), reveal (a card's hover chrome: hover or keyboard focus on a mouse, never on touch)
page & panel structure PageShell + pageToolbarSlot + pageHeaderSlot (PageHeader: a page fills the floating header) + pageHeading, PageScroller, Headings (PageHeading, SectionLabel), SkipLink + landmarks, FilterFold (B4), scrollFold (useScrollFold: a fixed header that folds once its content scrolls), PanelGroup, PanelGroups, Tabs + tabPanel, BackToTop + backToTopState (PageScroller keeps the page's end clear of it), FilterRail (the filter-rail shell a rail fills with its groups: title bar, customize, reset), stickyTop (a sticky header at the scroller's top turns its top fade off)
state & messaging QueryState, Banner, InlineNotice, CrashBoundary

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, 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.