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.
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'sSIZESare 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-scaleon the root font-size) scales boxes and padding with the label. Atext-[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. linkis 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.