Files
siftlode/frontend/src/components/ui/README.md
T
peter 50157cc2fd feat(player): clip mode - mark a range of what is playing and cut it into a download
Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.

- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
  handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
  Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
  timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
  AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
  at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
2026-10-10 03:58:27 +02:00

13 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), 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
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; NavRailGroup folds a set of rare pages into one row with a panel), form, DraftSaveBar, Combobox, FilterSelect, SegmentedToggle, SortControl, ColumnSortControl, PriorityStepper, UndoToolbar
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), 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 + 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)
page & panel structure PageShell + pageToolbarSlot + pageHeading, PageScroller, Headings (PageHeading, SectionLabel), SkipLink + landmarks, FilterFold (B4), scrollFold (useScrollFold: a fixed header that folds once its content scrolls), PanelGroup, PanelGroups, Tabs, BackToTop
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.