Files
improvise/harness/tests/narrow.spec.js
T
Edward Langley c8d715346f fix(web): a narrow window must not widen the document (improvise-tg27)
The report was "a narrow window clips grid columns without usable horizontal
scroll", and the ticket suspected a flex `min-width: auto` trap swallowing
`#grid`'s overflow. It was not that: `#grid` has `overflow: auto`, which
already zeroes a flex item's automatic minimum. What could not shrink was the
app bar.

Measured at 22ab58f on the grouped harness fixture, `documentElement.scrollWidth`
read 1011 at every window narrower than 1011px — a floor, not a slope, which is
the signature of a sum of `flex: none` reserves. Below it the PAGE grew a
horizontal scrollbar, and what it scrolled to was the bar's own right-hand end
sitting outside the window, while the grid slid off to the left.

`header` takes `flex-wrap: wrap`, chosen over a `@media` breakpoint because a
flex item wraps exactly when the line cannot hold it, so the desktop bar is
untouched by construction rather than by a number someone picked. `main` gets
the `min-width: 0` the ticket asked about, and the three side panels are capped
at 40vw so the grid keeps the larger share of a phone window.

What this deliberately does NOT do: make `#grid` scroll to columns beyond the
announced extent. The browser's Viewport is a real window (gotcha 44/47), so
those columns are never projected and no scrolling can reveal them; the
keyboard already reaches every one, and a spec now asserts that.

`harness/tests/narrow.spec.js` asserts `scrollWidth <= clientWidth`, which is a
question a browser answers exactly. `body { overflow-x: hidden }` was rejected
for making that assertion vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:35 -07:00

168 lines
7.6 KiB
JavaScript

// A narrow window must not widen the DOCUMENT (improvise-tg27). The app bar's
// reserve is a floor, and below the width that holds it the page grew a
// horizontal scrollbar that led to the bar's own tail rather than to grid
// columns. See context/code/clients.md and context/test-evidence/clients.md.
import { test, expect } from "@playwright/test";
/// `harness/fixtures/grouped.improv`: two categories on each axis, so the grid
/// is six data columns behind two row-header levels. Wide enough that a
/// phone-width window genuinely clips it -- the precondition every assertion
/// here rests on, and asserted rather than assumed.
async function theGrouped(page, width, height = 700) {
await page.setViewportSize({ width, height });
await page.goto("/app/index.html?doc=narrow-tg27&seed=grouped.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#view-name")).toHaveText("Grouped");
// The bar is not SETTLED when the grid first paints: `#status` composes the
// transport's fact and the document's, and it is the widest thing on a
// wrapped line. Measuring before it arrives measures a transient.
await expect(page.locator("#status")).toContainText("in-page worker");
}
/// What the page's own scrollers say. `documentElement` and not `#grid`: the
/// grid is ALLOWED to scroll -- it is the one scroller this app has -- and the
/// document is not.
const pageMetrics = (page) =>
page.evaluate(() => {
const d = document.documentElement;
const box = (n) => {
const r = n.getBoundingClientRect();
return { left: Math.round(r.left), right: Math.round(r.right), width: Math.round(r.width) };
};
const header = document.querySelector("header");
return {
win: innerWidth,
docScrollWidth: d.scrollWidth,
docClientWidth: d.clientWidth,
headerHeight: Math.round(header.getBoundingClientRect().height),
escaping: [...header.children]
.filter((n) => box(n).right > innerWidth + 1)
.map((n) => n.id || n.tagName),
grid: box(document.getElementById("grid")),
categories: box(document.getElementById("categories")),
dataCols:
(document.querySelector("#grid tbody tr:not(:has(th.group))")?.children.length ?? 0) - 2,
};
});
/// 360 is a phone in portrait, 520 the width the bug was reported at, 768 a
/// tablet. Each is below the ~1011px the bar's reserve needed, which is what
/// pinned the document at 1011px wide at all three.
const NARROW = [360, 520, 768];
test.describe("a narrow window does not widen the document (improvise-tg27)", () => {
test("a narrow window does not scroll the document horizontally", async ({ page }) => {
for (const width of NARROW) {
await theGrouped(page, width);
const m = await pageMetrics(page);
// The precondition. A window wide enough for the whole grid would
// satisfy everything below while proving nothing, so the spec states
// that the fixture really is clipped here -- six data columns exist.
expect(m.dataCols, `the fixture is not clipped at ${width}px`).toBeLessThan(6);
// The bug, stated once: the document was 1011px wide at every one of
// these widths, so the page -- not the grid -- carried the scrollbar.
expect(m.docScrollWidth, `the document scrolls horizontally at ${width}px`).toBeLessThanOrEqual(
m.docClientWidth,
);
// And what that scrollbar led to: the bar's own right-hand end, outside
// the window. Reported as ids so a failure says which children.
expect(m.escaping, `bar children outside the window at ${width}px`).toEqual([]);
}
});
test("the grid keeps the larger share of a phone-width window", async ({ page }) => {
await theGrouped(page, 360);
const m = await pageMetrics(page);
// `#categories` is 15rem and `flex: none` -- 240px of a 360px phone,
// leaving the grid 120. The assertion is an ORDERING and not a ratio: the
// exact split is a stylesheet number and pinning it catches nothing.
expect(m.grid.width, "the panel takes more of the window than the grid").toBeGreaterThan(
m.categories.width,
);
});
// The reflow is for windows that cannot hold the reserve, and for no others.
// 1280 is where improvise-n6t8's stability spec runs, so this is what says
// the bar that spec measures is untouched. The threshold itself is NOT
// asserted and is not a constant: `#status` composes the document's name, so
// a longer name reflows a wider window. clients.md.
test("at 1280px the bar holds its reserve on one line", async ({ page }) => {
await theGrouped(page, 1280, 800);
const wide = await pageMetrics(page);
expect(wide.headerHeight, "the bar wrapped at a width that holds it").toBeLessThan(50);
expect(wide.docScrollWidth).toBeLessThanOrEqual(wide.docClientWidth);
});
// The check-the-checker. Every assertion above is a negative -- "nothing
// overflows" -- and a negative is vacuous unless the measurement can SEE an
// overflow. So one is injected, at the same width, through the same path.
test("the measurement sees an overflow that is really there", async ({ page }) => {
await theGrouped(page, 520);
expect((await pageMetrics(page)).docScrollWidth).toBeLessThanOrEqual(520);
// A bar child that cannot shrink and cannot wrap: exactly the shape the
// fixed reserves had before `flex-wrap`. Injected on #doc-name because it
// held the largest of them (34ch).
await page.evaluate(() => {
const n = document.getElementById("doc-name");
n.style.flex = "none";
n.style.width = "1400px";
n.style.maxWidth = "none";
});
const hurt = await pageMetrics(page);
expect(
hurt.docScrollWidth,
"an injected 1400px reserve did not widen the document",
).toBeGreaterThan(hurt.docClientWidth);
expect(hurt.escaping, "an injected overflow was not seen escaping the bar").toContain(
"doc-name",
);
});
// Why clipping is acceptable at all: the browser's viewport is a REAL window
// (gotcha 44/47), so at 520px one data column is what fits and the rest are
// never projected -- no scrolling reveals them. The ticket's "or another
// explicit navigation" is the keyboard, and this asserts it reaches all six.
test("the keyboard reaches a column the window cannot hold", async ({ page }) => {
await theGrouped(page, 520);
await page.locator("#grid").focus();
const innermost = () =>
page.evaluate(() => {
const rows = document.querySelectorAll("#grid thead tr");
return [...rows[rows.length - 1].children].map((c) => c.textContent).filter(Boolean);
});
expect(await innermost()).toEqual(["Web"]);
// Five presses walk Jan/Web to Mar/Shop, the last of the six. Every
// intermediate step is checked: a client that jumped to the end would
// satisfy a last-column assertion alone.
const walk = [];
for (let i = 0; i < 5; i++) {
await page.keyboard.press("ArrowRight");
await expect.poll(async () => (await innermost())[0]).toBe(i % 2 === 0 ? "Shop" : "Web");
walk.push((await innermost())[0]);
}
expect(walk).toEqual(["Shop", "Web", "Shop", "Web", "Shop"]);
// The last column really is the last: the outer levels say Q2/Mar, which
// "Shop" alone cannot distinguish from Jan/Shop.
const levels = await page.evaluate(() =>
[...document.querySelectorAll("#grid thead tr")].map((r) =>
[...r.children]
.map((c) => c.textContent)
.filter(Boolean)
.join(""),
),
);
expect(levels).toEqual(["▼ Q2", "Mar", "Shop"]);
});
});