Files
siftlode/frontend/src/components/ui
peter 7d302f71b7 feat(e10): let an ultrawide buy bigger cards instead of more of them
The viewport contract has two ends, and only the floor was enforced. An auto-fill grid
answers "more room" with more columns, never bigger ones, so it settles just above its
minimum column at any width: measured at 3440px, the feed laid out 11 columns of 264px.
A feed diluted across an ultrawide fails the contract as surely as a broken one at the
floor.

Multi-column feed views now carry a `maxCols`; past it the leftover width flows into
the items. At 3440: cards 11x264 -> 8x370, cardsSmall 15 -> 11x264 (still visibly the
dense card, and never as wide as cards gets), tiles 7 -> 6x498. Each cap is set so the
item tops out near 1.5x its own minCol, which is what keeps a view recognisably itself.

A cap can only bind ABOVE the width at which that many columns already fit, so it is a
pure ultrawide affordance — 1024, 1920 and 2560 lay out exactly as before. Verified in
the browser at all three widths and across all five views.

The Channels grid needs no cap: the page's own 96rem --content-mw already bounds it.

Also renames VirtualGrid's `maxCol` to `colMaxWidth`. It was never a column count — it
is the single-column reading-width cap — and `maxCol`/`maxCols` one letter apart is the
kind of drift that has bitten this repo before.

The column maths moves to ui/gridColumns.ts as a pure function so both ends of the
contract are unit-testable without a layout engine; the tests assert the cap's numbers,
that it never binds at the floor, and that the dense card cannot grow into the standard
one.
2026-09-05 22:22:28 +02:00
..

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.

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 (the popover engine: arrows, typeahead, Escape, anchor-flip), ActionMenu, Modal, Overlay, SidePanel, Tooltip, Toaster
actions & inputs Button, form, DraftSaveBar, Combobox, FilterSelect, SegmentedToggle, SortControl, ColumnSortControl, PriorityStepper, UndoToolbar
data display DataTable, tableHeader, Pager, VirtualGrid, readAhead, Carousel, Avatar, CountBadge, ItemCount, FilterChipGroup
page & panel structure PageShell, 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.
  • Everything is 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.
  • 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.

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.