diff --git a/frontend/app/e2e/project-nav.spec.ts b/frontend/app/e2e/project-nav.spec.ts index eff287d0..cce75f25 100644 --- a/frontend/app/e2e/project-nav.spec.ts +++ b/frontend/app/e2e/project-nav.spec.ts @@ -315,3 +315,48 @@ test("the column's items are real links, so the section has an address before it await expect(page.getByTestId("schema-editor")).toBeVisible(); await expect(page.getByTestId("nav-schema")).toHaveAttribute("aria-current", "page"); }); + +/** + * The product's responsive floor, asserted rather than eyeballed. + * + * A page that scrolls sideways is a defect; a *local* scroller that does not + * widen the page is not, and this file's own strip is deliberately one. So the + * assertion is on the document, which is the thing a user drags. + * + * Every width here caught something real: the schema's action row came to 460px + * and overflowed to about 500px, the dataset tabs could not shrink past their + * labels and counts, and the gallery's batch actions and its state filter each + * widened the page on their own. A regression in any of them is a sideways + * scrollbar on a screen somebody works in all day. + * + * One test per route rather than one sweep: fifteen navigations in a single test + * runs past the suite's 20s budget, and a timeout reads as "the floor is broken" + * when it means "the test did too much". + */ +const FLOOR_WIDTHS = [320, 375, 480] as const; + +for (const route of [...SECTIONS, "batches-detail"] as const) { + const path = + route === "batches-detail" + ? `/projects/${PROJECT}/batches/${BATCH}` + : `/projects/${PROJECT}/${route}`; + + test(`${route} does not scroll the document sideways at the responsive floor`, async ({ + page, + }) => { + await page.setViewportSize({ width: FLOOR_WIDTHS[0], height: 800 }); + await openCold(page, path); + await expect(page.getByTestId("project-tabs")).toBeVisible(); + + for (const width of FLOOR_WIDTHS) { + await page.setViewportSize({ width, height: 800 }); + const overflow = await page.evaluate(() => { + const de = document.documentElement; + return de.scrollWidth - de.clientWidth; + }); + expect(overflow, `${path} at ${width}px scrolls the document sideways`).toBeLessThanOrEqual( + 0, + ); + } + }); +} diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index 4309c7d0..9a182eea 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -184,7 +184,15 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp > {/* The product's one tab shape — a row on a full-width hairline, the active tab's rule sitting on it — with the count as a chip beside the - two views whose size is the first thing anybody asks. */} + two views whose size is the first thing anybody asks. + + The strip scrolls within its own row rather than widening the page, + which is what the project navigation does at the same width and for + the same reason: a trigger carries a label and a count, so it has a + width it cannot shrink past, and truncating the labels would cost + more than a scroll does. The padding pair keeps the focus ring off + the scroller's clip. */} +
Overview @@ -198,6 +206,7 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp {releases.data !== undefined && {releases.data.total}} +
diff --git a/frontend/ui-core/src/screens/GalleryScreen.tsx b/frontend/ui-core/src/screens/GalleryScreen.tsx index ae416f6f..7eab3c71 100644 --- a/frontend/ui-core/src/screens/GalleryScreen.tsx +++ b/frontend/ui-core/src/screens/GalleryScreen.tsx @@ -708,7 +708,10 @@ function BatchHeader({ /> -
+ {/* Wraps rather than widening the page. Every control here is one a + batch's state offers, so none may be dropped at a narrow width; they + take a second line instead. */} +
{/* Draft only, and it opens the dialog rather than sending anything: approval carries a partition, pins the schema and cuts the jobs, and @@ -997,6 +1000,13 @@ function Toolbar({ > {showSegments && ( <> + {/* The row above already wraps, but the control is one joined pill and + is wider than the narrowest viewport on its own. It scrolls within + its own row rather than widening the page — the project navigation's + answer to the same shape — because squashing five state filters + costs more than a scroll does. The padding pair keeps the focus ring + off the scroller's clip. */} +
))}
+