diff --git a/frontend/src/components/ChannelLayoutGrid.tsx b/frontend/src/components/ChannelLayoutGrid.tsx index d15c6ffb..58544afe 100644 --- a/frontend/src/components/ChannelLayoutGrid.tsx +++ b/frontend/src/components/ChannelLayoutGrid.tsx @@ -84,7 +84,7 @@ export default function ChannelLayoutGrid({ items={rows} getKey={rowKey} minCol={spec.minCol} - maxCol={null} + colMaxWidth={null} rowEst={spec.rowEst} renderItem={renderCard} /> diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 22aa17bd..c47ed6bc 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -44,13 +44,14 @@ export default function VirtualFeed({ isFetchingNextPage, fetchNextPage, }: VirtualFeedProps) { - const { minCol, maxCol, thumb } = FEED_VIEW_SPEC[view]; + const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view]; return ( v.id} minCol={minCol} - maxCol={maxCol} + colMaxWidth={colMaxWidth} + maxCols={maxCols} rowEst={ROW_EST[view]} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} diff --git a/frontend/src/components/ui/VirtualGrid.tsx b/frontend/src/components/ui/VirtualGrid.tsx index 6add3475..6b952657 100644 --- a/frontend/src/components/ui/VirtualGrid.tsx +++ b/frontend/src/components/ui/VirtualGrid.tsx @@ -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(arr: T[], size: number): T[][] { if (size <= 1) return arr.map((x) => [x]); const out: T[][] = []; @@ -56,8 +50,16 @@ export interface VirtualGridProps { /** 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({ items, getKey, minCol, - maxCol = null, + colMaxWidth = null, + maxCols = null, rowEst, renderItem, hasNextPage = false, @@ -112,18 +115,16 @@ export default function VirtualGrid({ 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({ className={singleCol ? "mx-auto pb-1" : "grid gap-4"} style={ singleCol - ? { maxWidth: maxCol ?? undefined } + ? { maxWidth: colMaxWidth ?? undefined } : { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` } } > diff --git a/frontend/src/components/ui/gridColumns.test.ts b/frontend/src/components/ui/gridColumns.test.ts new file mode 100644 index 00000000..7e4c9e9e --- /dev/null +++ b/frontend/src/components/ui/gridColumns.test.ts @@ -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")); + }); +}); diff --git a/frontend/src/components/ui/gridColumns.ts b/frontend/src/components/ui/gridColumns.ts new file mode 100644 index 00000000..34e1b0c1 --- /dev/null +++ b/frontend/src/components/ui/gridColumns.ts @@ -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); +} diff --git a/frontend/src/lib/feedView.ts b/frontend/src/lib/feedView.ts index 7c69fd46..b822872c 100644 --- a/frontend/src/lib/feedView.ts +++ b/frontend/src/lib/feedView.ts @@ -21,7 +21,15 @@ export interface FeedViewSpec { minCol: number | null; /** 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. */ - 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, * saved, resume bar) then move into the meta line, since Thumb is what draws them. */ thumb: boolean; @@ -37,7 +45,9 @@ export const FEED_VIEW_SPEC: Record = { cards: { family: "card", minCol: 260, - maxCol: null, + colMaxWidth: null, + // 8 -> 369px cards at 3440 (vs 11 x 264 uncapped); binds only above ~2560. + maxCols: 8, thumb: true, dense: false, actionsBelow: false, @@ -45,7 +55,9 @@ export const FEED_VIEW_SPEC: Record = { cardsSmall: { family: "card", 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, dense: true, actionsBelow: false, @@ -57,7 +69,8 @@ export const FEED_VIEW_SPEC: Record = { rows: { family: "row", minCol: null, - maxCol: 896, + colMaxWidth: 896, + maxCols: null, thumb: true, dense: false, actionsBelow: false, @@ -68,7 +81,8 @@ export const FEED_VIEW_SPEC: Record = { rowsCompact: { family: "row", minCol: null, - maxCol: 1472, + colMaxWidth: 1472, + maxCols: null, thumb: false, dense: false, actionsBelow: false, @@ -83,7 +97,11 @@ export const FEED_VIEW_SPEC: Record = { tiles: { family: "row", 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, dense: false, actionsBelow: true,