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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user