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:
@@ -84,7 +84,7 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
items={rows}
|
items={rows}
|
||||||
getKey={rowKey}
|
getKey={rowKey}
|
||||||
minCol={spec.minCol}
|
minCol={spec.minCol}
|
||||||
maxCol={null}
|
colMaxWidth={null}
|
||||||
rowEst={spec.rowEst}
|
rowEst={spec.rowEst}
|
||||||
renderItem={renderCard}
|
renderItem={renderCard}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,13 +44,14 @@ export default function VirtualFeed({
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
}: VirtualFeedProps) {
|
}: VirtualFeedProps) {
|
||||||
const { minCol, maxCol, thumb } = FEED_VIEW_SPEC[view];
|
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
|
||||||
return (
|
return (
|
||||||
<VirtualGrid
|
<VirtualGrid
|
||||||
items={items}
|
items={items}
|
||||||
getKey={(v) => v.id}
|
getKey={(v) => v.id}
|
||||||
minCol={minCol}
|
minCol={minCol}
|
||||||
maxCol={maxCol}
|
colMaxWidth={colMaxWidth}
|
||||||
|
maxCols={maxCols}
|
||||||
rowEst={ROW_EST[view]}
|
rowEst={ROW_EST[view]}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
||||||
import { shouldPrefetchNext } from "./readAhead";
|
import { shouldPrefetchNext } from "./readAhead";
|
||||||
|
|
||||||
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
|
// 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
|
// Column sizing mirrors the CSS the feed used before virtualization
|
||||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||||
const GRID_GAP = 16;
|
|
||||||
const LIST_GAP = 4;
|
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[][] {
|
function chunk<T>(arr: T[], size: number): T[][] {
|
||||||
if (size <= 1) return arr.map((x) => [x]);
|
if (size <= 1) return arr.map((x) => [x]);
|
||||||
const out: T[][] = [];
|
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
|
/** 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. */
|
* from this and the container width, so every multi-column layout reflows for free. */
|
||||||
minCol: number | null;
|
minCol: number | null;
|
||||||
/** Single-column layout only: the reading-width cap in px (null = fill). */
|
/** Single-column layout only (`minCol == null`): the reading-width cap in px (null = fill). */
|
||||||
maxCol?: number | null;
|
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
|
/** 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
|
* 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. */
|
* bad estimate makes the scrollbar jump as you scroll in. */
|
||||||
@@ -76,7 +78,8 @@ export default function VirtualGrid<T>({
|
|||||||
items,
|
items,
|
||||||
getKey,
|
getKey,
|
||||||
minCol,
|
minCol,
|
||||||
maxCol = null,
|
colMaxWidth = null,
|
||||||
|
maxCols = null,
|
||||||
rowEst,
|
rowEst,
|
||||||
renderItem,
|
renderItem,
|
||||||
hasNextPage = false,
|
hasNextPage = false,
|
||||||
@@ -112,18 +115,16 @@ export default function VirtualGrid<T>({
|
|||||||
setScrollMargin(m);
|
setScrollMargin(m);
|
||||||
}
|
}
|
||||||
const w = node.clientWidth;
|
const w = node.clientWidth;
|
||||||
const cols = columnsFor(minCol, w);
|
const cols = columnsFor(minCol, w, maxCols);
|
||||||
setColCount(cols);
|
setColCount(cols);
|
||||||
setItemWidth(
|
setItemWidth(minCol == null ? Math.min(w, colMaxWidth ?? w) : itemWidthFor(w, cols));
|
||||||
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
measure();
|
measure();
|
||||||
const ro = new ResizeObserver(measure);
|
const ro = new ResizeObserver(measure);
|
||||||
ro.observe(node);
|
ro.observe(node);
|
||||||
if (scrollEl) ro.observe(scrollEl);
|
if (scrollEl) ro.observe(scrollEl);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [scrollEl, minCol, maxCol]);
|
}, [scrollEl, minCol, colMaxWidth, maxCols]);
|
||||||
|
|
||||||
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
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"}
|
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||||
style={
|
style={
|
||||||
singleCol
|
singleCol
|
||||||
? { maxWidth: maxCol ?? undefined }
|
? { maxWidth: colMaxWidth ?? undefined }
|
||||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { FEED_VIEWS, FEED_VIEW_SPEC } from "../../lib/feedView";
|
||||||
|
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
||||||
|
|
||||||
|
// The floor of the viewport contract (1024x768) and its ceiling (ultrawide) are both column-count
|
||||||
|
// questions, so they are both testable here without a layout engine.
|
||||||
|
const FLOOR_GRID = 700; // the feed's grid width at a 1024px viewport with the filter rail pinned
|
||||||
|
const ULTRAWIDE_GRID = 3069; // measured: the feed's grid width at a 3440px viewport
|
||||||
|
|
||||||
|
describe("columnsFor", () => {
|
||||||
|
it("fits as many columns as the width allows when uncapped", () => {
|
||||||
|
expect(columnsFor(260, 3069, null)).toBe(11); // the pre-cap ultrawide measurement
|
||||||
|
expect(columnsFor(260, 700, null)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns 0 columns, however narrow", () => {
|
||||||
|
expect(columnsFor(260, 10, null)).toBe(1);
|
||||||
|
expect(columnsFor(260, 0, null)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 1 for the single-column layout regardless of width or cap", () => {
|
||||||
|
expect(columnsFor(null, 3069, 8)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the count, and the saved width goes into the items", () => {
|
||||||
|
expect(columnsFor(260, ULTRAWIDE_GRID, 8)).toBe(8);
|
||||||
|
expect(itemWidthFor(ULTRAWIDE_GRID, 11)).toBe(264); // uncapped: pinned near minCol
|
||||||
|
expect(itemWidthFor(ULTRAWIDE_GRID, 8)).toBe(369); // capped: the width lands in the card
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing below the width where that many columns already fit", () => {
|
||||||
|
// The cap must be a pure ultrawide affordance: at every width up to the point where 8 columns
|
||||||
|
// fit, capping at 8 has to return exactly what the uncapped grid would have.
|
||||||
|
const eightFitsAt = 8 * (260 + GRID_GAP) - GRID_GAP;
|
||||||
|
for (let w = 320; w < eightFitsAt; w += 17) {
|
||||||
|
expect(columnsFor(260, w, 8)).toBe(columnsFor(260, w, null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FEED_VIEW_SPEC ultrawide policy", () => {
|
||||||
|
it("caps every multi-column view and leaves the single-column ones to their width cap", () => {
|
||||||
|
for (const view of FEED_VIEWS) {
|
||||||
|
const { minCol, maxCols, colMaxWidth } = FEED_VIEW_SPEC[view];
|
||||||
|
if (minCol == null) {
|
||||||
|
// A single column is capped by reading width, not by a column count.
|
||||||
|
expect(maxCols, `${view} is single-column`).toBeNull();
|
||||||
|
expect(colMaxWidth, `${view} needs a reading-width cap`).not.toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(maxCols, `${view} would dilute on an ultrawide`).not.toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a capped view recognisably itself — its item stays near its own minCol", () => {
|
||||||
|
// The policy behind each number: on a 3440px ultrawide an item tops out around 1.5x the view's
|
||||||
|
// own minimum. Looser and the view dilutes anyway; tighter and e.g. cardsSmall grows into cards.
|
||||||
|
for (const view of FEED_VIEWS) {
|
||||||
|
const { minCol, maxCols } = FEED_VIEW_SPEC[view];
|
||||||
|
if (minCol == null || maxCols == null) continue;
|
||||||
|
const w = itemWidthFor(ULTRAWIDE_GRID, columnsFor(minCol, ULTRAWIDE_GRID, maxCols));
|
||||||
|
expect(w, `${view} item width at 3440`).toBeGreaterThanOrEqual(minCol);
|
||||||
|
expect(w, `${view} item width at 3440`).toBeLessThanOrEqual(Math.round(minCol * 1.6));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the 1024px floor untouched — the cap never binds there", () => {
|
||||||
|
for (const view of FEED_VIEWS) {
|
||||||
|
const { minCol, maxCols } = FEED_VIEW_SPEC[view];
|
||||||
|
if (minCol == null) continue;
|
||||||
|
expect(columnsFor(minCol, FLOOR_GRID, maxCols)).toBe(columnsFor(minCol, FLOOR_GRID, null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets the dense card grow as wide as the standard card", () => {
|
||||||
|
const at = (v: "cards" | "cardsSmall") => {
|
||||||
|
const { minCol, maxCols } = FEED_VIEW_SPEC[v];
|
||||||
|
return itemWidthFor(ULTRAWIDE_GRID, columnsFor(minCol, ULTRAWIDE_GRID, maxCols));
|
||||||
|
};
|
||||||
|
expect(at("cardsSmall")).toBeLessThan(at("cards"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -21,7 +21,15 @@ export interface FeedViewSpec {
|
|||||||
minCol: number | null;
|
minCol: number | null;
|
||||||
/** Single-column views only: the reading-width cap in px (null = fill). A long line is hard to
|
/** Single-column views only: the reading-width cap in px (null = fill). A long line is hard to
|
||||||
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
|
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
|
||||||
maxCol: number | null;
|
colMaxWidth: number | null;
|
||||||
|
/** Multi-column views only: the most columns to lay out (null = as many as fit). The UPPER end of
|
||||||
|
* the 1024x768..ultrawide contract (E10 S4). An auto-fill grid always settles just above `minCol`,
|
||||||
|
* so without a cap a wider screen buys more, smaller items rather than better ones — measured at
|
||||||
|
* 3440px, `cards` laid out 11 columns of 264px. Each cap here is set so the item tops out near
|
||||||
|
* 1.5x its own `minCol` on a 3440px ultrawide, which keeps each view recognisably itself (a capped
|
||||||
|
* `cardsSmall` must not grow into a `cards`). Below the width where that many columns already fit
|
||||||
|
* the cap does nothing at all, so 1024 through 2560 are byte-identical to before it existed. */
|
||||||
|
maxCols: number | null;
|
||||||
/** Show the video thumbnail. False only for the bare row — whose badges (duration, live state,
|
/** Show the video thumbnail. False only for the bare row — whose badges (duration, live state,
|
||||||
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
|
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
|
||||||
thumb: boolean;
|
thumb: boolean;
|
||||||
@@ -37,7 +45,9 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
|||||||
cards: {
|
cards: {
|
||||||
family: "card",
|
family: "card",
|
||||||
minCol: 260,
|
minCol: 260,
|
||||||
maxCol: null,
|
colMaxWidth: null,
|
||||||
|
// 8 -> 369px cards at 3440 (vs 11 x 264 uncapped); binds only above ~2560.
|
||||||
|
maxCols: 8,
|
||||||
thumb: true,
|
thumb: true,
|
||||||
dense: false,
|
dense: false,
|
||||||
actionsBelow: false,
|
actionsBelow: false,
|
||||||
@@ -45,7 +55,9 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
|||||||
cardsSmall: {
|
cardsSmall: {
|
||||||
family: "card",
|
family: "card",
|
||||||
minCol: 180,
|
minCol: 180,
|
||||||
maxCol: null,
|
colMaxWidth: null,
|
||||||
|
// 11 -> 264px at 3440: still visibly the DENSE card, and never as wide as `cards` gets.
|
||||||
|
maxCols: 11,
|
||||||
thumb: true,
|
thumb: true,
|
||||||
dense: true,
|
dense: true,
|
||||||
actionsBelow: false,
|
actionsBelow: false,
|
||||||
@@ -57,7 +69,8 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
|||||||
rows: {
|
rows: {
|
||||||
family: "row",
|
family: "row",
|
||||||
minCol: null,
|
minCol: null,
|
||||||
maxCol: 896,
|
colMaxWidth: 896,
|
||||||
|
maxCols: null,
|
||||||
thumb: true,
|
thumb: true,
|
||||||
dense: false,
|
dense: false,
|
||||||
actionsBelow: false,
|
actionsBelow: false,
|
||||||
@@ -68,7 +81,8 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
|||||||
rowsCompact: {
|
rowsCompact: {
|
||||||
family: "row",
|
family: "row",
|
||||||
minCol: null,
|
minCol: null,
|
||||||
maxCol: 1472,
|
colMaxWidth: 1472,
|
||||||
|
maxCols: null,
|
||||||
thumb: false,
|
thumb: false,
|
||||||
dense: false,
|
dense: false,
|
||||||
actionsBelow: false,
|
actionsBelow: false,
|
||||||
@@ -83,7 +97,11 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
|||||||
tiles: {
|
tiles: {
|
||||||
family: "row",
|
family: "row",
|
||||||
minCol: 380,
|
minCol: 380,
|
||||||
maxCol: null,
|
colMaxWidth: null,
|
||||||
|
// 6 -> 498px tiles at 3440. A tile spends extra width on its TEXT column rather than on a bigger
|
||||||
|
// thumbnail, so it tolerates dilution better than a card — but it still bottoms out at its 380px
|
||||||
|
// minimum on an ultrawide, which is the same waste, so it gets the same treatment.
|
||||||
|
maxCols: 6,
|
||||||
thumb: true,
|
thumb: true,
|
||||||
dense: false,
|
dense: false,
|
||||||
actionsBelow: true,
|
actionsBelow: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user