c8d715346f
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
168 lines
7.6 KiB
JavaScript
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"]);
|
|
});
|
|
});
|