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:
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
||||
import { shouldPrefetchNext } from "./readAhead";
|
||||
|
||||
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
|
||||
@@ -22,15 +23,8 @@ import { shouldPrefetchNext } from "./readAhead";
|
||||
//
|
||||
// Column sizing mirrors the CSS the feed used before virtualization
|
||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||
const GRID_GAP = 16;
|
||||
const LIST_GAP = 4;
|
||||
|
||||
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
|
||||
function columnsFor(minCol: number | null, width: number): number {
|
||||
if (minCol == null) return 1;
|
||||
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
|
||||
}
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
if (size <= 1) return arr.map((x) => [x]);
|
||||
const out: T[][] = [];
|
||||
@@ -56,8 +50,16 @@ export interface VirtualGridProps<T> {
|
||||
/** Narrowest grid column in px, or null for ONE full-width column. The column count is derived
|
||||
* from this and the container width, so every multi-column layout reflows for free. */
|
||||
minCol: number | null;
|
||||
/** Single-column layout only: the reading-width cap in px (null = fill). */
|
||||
maxCol?: number | null;
|
||||
/** Single-column layout only (`minCol == null`): the reading-width cap in px (null = fill). */
|
||||
colMaxWidth?: number | null;
|
||||
/** Multi-column layout only (`minCol != null`): the most columns to lay out, or null for as many
|
||||
* as fit. This is the UPPER end of the viewport contract (E10 S4): an auto-fill grid always settles
|
||||
* just above its minimum column, so on an ultrawide it answers "more room" with more, smaller
|
||||
* items forever — 3440px gave the feed 11 columns of 264px. With a cap the extra width goes into
|
||||
* the items instead (8 columns of 384px), which is what a screen watched from a distance wants.
|
||||
* It only ever binds ABOVE the width where that many columns already fit, so narrower layouts —
|
||||
* including the 1024px floor and 1920/2560 — are untouched. */
|
||||
maxCols?: number | null;
|
||||
/** Estimated row height before measurement; real heights are measured per-row, so this only
|
||||
* affects the very first paint and the scrollbar's initial guess. Measure it, don't guess — a
|
||||
* bad estimate makes the scrollbar jump as you scroll in. */
|
||||
@@ -76,7 +78,8 @@ export default function VirtualGrid<T>({
|
||||
items,
|
||||
getKey,
|
||||
minCol,
|
||||
maxCol = null,
|
||||
colMaxWidth = null,
|
||||
maxCols = null,
|
||||
rowEst,
|
||||
renderItem,
|
||||
hasNextPage = false,
|
||||
@@ -112,18 +115,16 @@ export default function VirtualGrid<T>({
|
||||
setScrollMargin(m);
|
||||
}
|
||||
const w = node.clientWidth;
|
||||
const cols = columnsFor(minCol, w);
|
||||
const cols = columnsFor(minCol, w, maxCols);
|
||||
setColCount(cols);
|
||||
setItemWidth(
|
||||
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols),
|
||||
);
|
||||
setItemWidth(minCol == null ? Math.min(w, colMaxWidth ?? w) : itemWidthFor(w, cols));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
if (scrollEl) ro.observe(scrollEl);
|
||||
return () => ro.disconnect();
|
||||
}, [scrollEl, minCol, maxCol]);
|
||||
}, [scrollEl, minCol, colMaxWidth, maxCols]);
|
||||
|
||||
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||
|
||||
@@ -177,7 +178,7 @@ export default function VirtualGrid<T>({
|
||||
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||
style={
|
||||
singleCol
|
||||
? { maxWidth: maxCol ?? undefined }
|
||||
? { maxWidth: colMaxWidth ?? undefined }
|
||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user