Files
siftlode/frontend/src/components/ui/gridColumns.test.ts
T
peter 80dea4f307 fix(e10): review round 1, part 2 — the docs the epic invalidated, and three contracts made real
Finishes round 1. Part 1 took the runtime bugs; this takes the prose that
had gone false and the three "the comment says so" gaps behind it.

CLAUDE.md is auto-loaded into every agent session and ui/README.md is
not, so an architecture paragraph that still described a flat components
bag was the most expensive stale sentence in the repo: the next agent
adding a popover reads it, sees no platform layer, and writes the generic
component into components/ — the invisible-layer failure this epic exists
to end. It now names components/ui/ and says the boundary is lint-enforced.
The canonical-patterns table gained the three rows the epic established:
ui/Button over a hand-rolled <button>, a keymap row over a private keydown
listener, and disabled-when-busy vs inert-when-inapplicable.

ui/README.md's inventory was written in this branch's first commit and
never learned about CommandPalette, ContextMenu/useContextMenu,
KeyboardHelp or gridColumns — in a section whose own closing rule is that
a second component painting the same thing is how the last inconsistency
happened. It also claimed "everything is rem-derived" while VirtualGrid's
minCol/GRID_GAP are deliberately px; that is now stated as the exception
it is, and a new section records which promises here are tests rather
than sentences.

Three contracts stopped being prose:

- A text-less Button must carry a name. "Required for iconOnly" was a doc
  comment, which is why the migration shipped two nameless icon buttons
  through eight green lanes. It is a union now, covering both spellings
  (iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or
  title. It caught one more on the way in: Banner's dismiss took an
  optional dismissTitle, so it could render unnamed — it falls back to
  common.close now.
- gridColumns' gutter arithmetic. Every existing assertion survived
  replacing floor((w + GAP) / (minCol + GAP)) with the naive
  floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are
  cap-bound, and the loops compare columnsFor against itself. Pinned at
  the exact-fit boundaries where the two first disagree (536 and 828).
- ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem
  button between two 1.75rem ones — and on a coarse pointer .touch-target
  grew only the migrated one to 2.75rem while the destructive Revoke
  stayed small.

Also: the maxCols docstring said 8 columns of 384px where the test it
shipped with asserts 369.
2026-09-06 01:30:40 +02:00

99 lines
4.8 KiB
TypeScript

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);
});
// The GUTTERS have to be in the count, not just in itemWidthFor. Every other assertion in this file
// survives replacing `floor((w + GAP) / (minCol + GAP))` with the naive `floor(w / (minCol + GAP))`
// — the fixed widths agree, the capped cases are cap-bound, and the two loops compare columnsFor
// against itself, so they cancel any shared change (E10 review round 1). These are the exact-fit
// boundaries where the two formulas first disagree: an n-column row needs n*minCol + (n-1)*GAP, so
// at exactly that width the naive form loses the last column and wastes a third of the row.
it("counts the gutters, not one gap per column", () => {
const exactly3 = 3 * 260 + 2 * GRID_GAP; // 828
expect(columnsFor(260, exactly3, null)).toBe(3);
expect(columnsFor(260, exactly3 - 1, null)).toBe(2);
const exactly2 = 2 * 260 + GRID_GAP; // 536 — the FIRST width where the naive form is wrong
expect(columnsFor(260, exactly2, null)).toBe(2);
expect(columnsFor(260, exactly2 - 1, null)).toBe(1);
});
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"));
});
});