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.
This commit is contained in:
2026-09-05 22:22:28 +02:00
parent f99240d8ca
commit 7d302f71b7
6 changed files with 159 additions and 25 deletions
+31
View File
@@ -0,0 +1,31 @@
// VirtualGrid's column-count policy, extracted as a pure function so the viewport contract's UPPER
// end is unit-testable without a layout engine (the lower end, the 1024x768 floor, needs no policy —
// the grid simply drops to the columns that fit).
//
// An auto-fill grid answers "more room" with more columns, never bigger ones, so it always settles
// just above its minimum column width no matter how wide the screen is. Measured at 3440px (E10 S4),
// the feed laid out 11 columns of 264px — a diluted feed is as bad a failure of the viewport contract
// as a broken one, so `maxCols` stops the multiplication and lets the leftover width flow into the
// items instead. The cap can only bind ABOVE the width at which that many columns already fit, which
// is what keeps every narrower layout — the 1024px floor included — bit-for-bit unchanged.
/** The grid's gutter in px; the column maths has to net it out on both sides of the division. */
export const GRID_GAP = 16;
/** Columns that fit `width` (never more than `maxCols`), or 1 for the single-column layout
* (`minCol == null`). */
export function columnsFor(
minCol: number | null,
width: number,
maxCols: number | null = null,
): number {
if (minCol == null) return 1;
const fit = Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
return maxCols == null ? fit : Math.min(fit, maxCols);
}
/** The px width one item gets once `columnsFor` has chosen the count — i.e. where the width saved by
* the cap actually goes. */
export function itemWidthFor(width: number, cols: number): number {
return Math.floor((width - GRID_GAP * (cols - 1)) / cols);
}