diff --git a/playwright.config.ts b/playwright.config.ts index f5e009ce29..f0cf88c090 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,7 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // they share a spec file. Every required browser project uses the same // production matcher and tag exclusion. const productionSpecPattern = - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|chrome-scroll|therapy-nav-scroll|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/; const mockupSpecPattern = /.*ui-(document-top-navigation-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; @@ -24,7 +24,7 @@ const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|document-top-navigation-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|chrome-scroll|therapy-nav-scroll|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|document-top-navigation-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/src/app/globals.css b/src/app/globals.css index ae28c2af6b..845c33c579 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2329,9 +2329,32 @@ td, cannot overflow at any width or text size (WCAG 1.4.4 Resize Text, 1.4.10 Reflow). - Budgets, measured against this app's phone type ramp with slot padding - counted: four full labels need 394px and fit from 26rem; three plus More need - 256px and fit from 16rem. + Slots size to their CONTENT at every band, never to equal `1fr` tracks. + Equal tracks make the widest slot set what every slot needs, and the label's + `truncate` then hides the shortfall — silently, because nothing overflows and + nothing fails. Measured in Chromium on 2026-07-29 against the Therapy set, + that clipped at every phone width: 320px showed 15 of Compare's 56px, and + 430px was WORSE at three of four labels clipped, because crossing a band adds + a slot to the same space. See ledger #113. + + Budgets are therefore the sum of the slots' intrinsic widths, remeasured: + Search 92.4px, Compare 144.3px, Recommend 125.6px, Pathways 108.5px, More + ~77px, plus the bar's own 8px inline padding. Three slots (first two + More) + need ~322px; four need ~479px. + + The thresholds carry ~8% over those numbers rather than a pixel or two, and + that margin is not padding for its own sake: the SAME labels measure wider on + a CI runner than on a development box, because the variable face rasterises + differently per environment. A first attempt at 21/31rem passed locally and + failed on CI by exactly one pixel ("Compare" needed 59px and had 58px, having + needed 56px locally). A threshold calibrated to one machine's font metrics is + calibrated to nothing, so these sit clear of the whole spread. + + These are calibrated to the widest label set currently shipped, which a fixed + `rem` threshold cannot generalise to an arbitrary item list. That gap is held + by a gate, not by hope: `ui-mode-nav-density.spec.ts` asserts no label + truncates at either band boundary, so a mode whose labels are longer fails CI + rather than shipping clipped words. ───────────────────────────────────────────────────────────────────────── */ @utility mode-nav { @@ -2360,21 +2383,22 @@ td, display: none; } -@container mode-nav (min-width: 16rem) { +@container mode-nav (min-width: 22rem) { .mode-nav__control { display: none; } + /* Content-sized and left-aligned at EVERY band, which is what the widest band + always did: "the bar reads as a continuation of the header rather than four + columns stretched across it". Extending it downward is the fix for #113 — + one layout mode instead of two, and no track can be narrower than its own + label. */ .mode-nav__bar { - display: grid; - grid-auto-flow: column; - /* Equal columns, so outer margins and inter-slot gaps are identical by - construction rather than by hand-tuned padding. */ - grid-auto-columns: 1fr; + display: flex; } } -@container mode-nav (min-width: 26rem) { +@container mode-nav (min-width: 33rem) { .mode-nav__slot[data-band="4"] { display: flex; } @@ -2384,7 +2408,7 @@ td, } } -@container mode-nav (min-width: 34rem) { +@container mode-nav (min-width: 42rem) { .mode-nav__slot[data-band="5"] { display: flex; } @@ -2392,12 +2416,6 @@ td, .mode-nav__slot[data-until="4"] { display: none; } - - /* Wide: slots size to their content and sit left, so the bar reads as a - continuation of the header rather than four columns stretched across it. */ - .mode-nav__bar { - display: flex; - } } /* Motion keyframes (suppressed under prefers-reduced-motion below) */ diff --git a/tests/mode-nav-contract.test.ts b/tests/mode-nav-contract.test.ts index 7c7d506ec8..3e337ec6f1 100644 --- a/tests/mode-nav-contract.test.ts +++ b/tests/mode-nav-contract.test.ts @@ -72,7 +72,13 @@ describe("ModeNav band planning", () => { describe("ModeNav density contract", () => { it("chooses density by container width in rem, never px", () => { const thresholds = [...modeNavCss.matchAll(/@container mode-nav \(min-width: ([^)]+)\)/g)].map((m) => m[1].trim()); - expect(thresholds).toEqual(["16rem", "26rem", "34rem"]); + // Raised from 16/26/34 by ledger #113. Those were budgeted for equal `1fr` + // tracks and were short by roughly the count badge; the slots are now + // content-sized, so each threshold is the measured sum of the intrinsic + // widths it must hold, plus ~8% — enough to absorb the font-metric + // difference between a development box and a CI runner, which is real and + // failed a first attempt at 21/31rem by a single pixel. + expect(thresholds).toEqual(["22rem", "33rem", "42rem"]); // The unit is the mechanism: raising the browser or OS text size grows the // root font, so a phone crosses a threshold exactly when its labels would @@ -100,7 +106,7 @@ describe("ModeNav density contract", () => { expect(baseBar).toContain("display: none"); expect(baseControl).toContain("display: flex"); // The bar is only ever revealed inside a container query. - expect(modeNavCss.indexOf(".mode-nav__bar {\n display: grid")).toBeGreaterThan( + expect(modeNavCss.indexOf(".mode-nav__bar {\n display: flex")).toBeGreaterThan( modeNavCss.indexOf("@container mode-nav"), ); }); @@ -111,10 +117,22 @@ describe("ModeNav density contract", () => { expect(modeNavCss).not.toMatch(/overflow(-x)?:\s*(auto|scroll)/); }); - it("distributes phone slots as equal columns", () => { - // Equal columns make the outer margins and inter-slot gaps identical by - // construction, rather than by hand-tuned padding that drifts. - expect(modeNavCss).toContain("grid-auto-columns: 1fr"); + it("sizes slots to their content, never to equal tracks", () => { + // Ledger #113: equal `1fr` tracks make the WIDEST slot set what every slot + // needs, and the label's `truncate` then hides the shortfall silently — + // nothing overflows, nothing fails, the word is just gone. Measured in + // Chromium, that clipped at every phone width. Content-sized slots cannot + // be narrower than their own label, so the failure mode is gone rather + // than merely retuned. + expect(modeNavCss).not.toContain("grid-auto-columns"); + expect(modeNavCss).not.toMatch(/\.mode-nav__bar\s*\{[^}]*display:\s*grid/); + + // Exactly one bar layout, so a future edit cannot reintroduce a second one + // that behaves differently at a band nobody re-measures. + const barDisplays = [...modeNavCss.matchAll(/\.mode-nav__bar\s*\{([^}]*)\}/g)] + .map((match) => match[1].match(/display:\s*([a-z]+)/)?.[1]) + .filter(Boolean); + expect(barDisplays).toEqual(["none", "flex"]); }); }); diff --git a/tests/ui-mode-nav-density.spec.ts b/tests/ui-mode-nav-density.spec.ts new file mode 100644 index 0000000000..637c11b591 --- /dev/null +++ b/tests/ui-mode-nav-density.spec.ts @@ -0,0 +1,146 @@ +import { expect, test, type Page } from "playwright/test"; + +/** + * `ModeNav` promises that a slot shows its real word or folds into More — it + * never abbreviates. Ledger #113: that promise was broken at every phone width + * because the lower bands used equal `1fr` tracks, so the widest slot set what + * every slot got and the label's `truncate` hid the shortfall. Nothing + * overflowed and nothing failed; the word was simply gone. + * + * A static `rem` threshold cannot prove fit for an arbitrary item list, and + * `ModeNav` is shared — Therapy is only its first consumer. So the contract is + * held here instead: at each band boundary every rendered label must be fully + * visible. A mode whose labels are longer than the thresholds allow fails this + * spec rather than shipping clipped words. + * + * Widths are the boundaries themselves (22rem = 352px, 33rem = 528px) and one + * pixel either side, because a threshold that is one slot too generous only + * misbehaves in the first pixels above it. That makes this the most valuable + * place to assert and the least forgiving: the same labels rasterise wider on a + * CI runner than on a development box, so the thresholds carry ~8% headroom + * rather than the pixel or two a single machine's measurements would suggest. + */ + +const BAND_3_PX = 352; // 22rem — first two destinations + More +const BAND_4_PX = 528; // 33rem — all four destinations + +/** + * The nav as it is actually anchored, not as it is momentarily served. + * + * Two things make a bare `getByTestId("mode-nav")` wrong here. Next streams the + * server-rendered copy while the client tree hydrates, so the id can resolve to + * two elements and Playwright fails on strict mode (ledger #093). And the + * density decision is a CONTAINER query: it only means anything once the bar is + * inside the container it will live in, because `ModeNavHeaderPortal` resolves + * its host in a layout effect and renders in page flow until then — a different + * width from the header slot. Scoping to the collapse host solves both: it + * names exactly one element, and it is the one the user sees. + */ +const anchoredNav = '[data-testid="universal-header-collapse"] [data-testid="mode-nav"]'; + +async function gotoTherapySearch(page: Page) { + await page.goto("/therapy-compass/search?q=CBT&run=1", { waitUntil: "domcontentloaded" }); + await expect(page.locator(anchoredNav)).toBeVisible({ timeout: 20_000 }); +} + +type NavState = { + state: "bar" | "collapsed" | "none"; + labels: { text: string; clipped: boolean; scrollWidth: number; clientWidth: number }[]; + barOverflows: boolean; +}; + +async function readNav(page: Page): Promise { + return page.evaluate((selector) => { + const nav = document.querySelector(selector); + const bar = nav?.querySelector(".mode-nav__bar") ?? null; + const control = nav?.querySelector(".mode-nav__control") ?? null; + const shown = (node: Element | null) => Boolean(node) && getComputedStyle(node!).display !== "none"; + + const labels: NavState["labels"] = []; + if (shown(bar)) { + for (const slot of bar!.querySelectorAll("li")) { + if (getComputedStyle(slot).display === "none") continue; + const label = slot.querySelector("span.truncate"); + if (!(label instanceof HTMLElement)) continue; + labels.push({ + text: label.textContent ?? "", + // A truncated label reports more content than it can show. The 0.5px + // slack absorbs sub-pixel text metrics, not a missing character. + clipped: label.scrollWidth > label.clientWidth + 0.5, + scrollWidth: label.scrollWidth, + clientWidth: label.clientWidth, + }); + } + } + return { + state: shown(bar) ? "bar" : shown(control) ? "collapsed" : "none", + labels, + barOverflows: bar instanceof HTMLElement ? bar.scrollWidth > bar.clientWidth + 1 : false, + }; + }, anchoredNav); +} + +function expectNoClippedLabels(nav: NavState, where: string) { + const clipped = nav.labels.filter((label) => label.clipped); + expect( + clipped, + `${where}: ${clipped.map((l) => `"${l.text}" needs ${l.scrollWidth}px, has ${l.clientWidth}px`).join("; ")}`, + ).toEqual([]); + // Fitting by overflowing sideways is not fitting. A bar wider than its + // container hides destinations behind an edge people never scroll to. + expect(nav.barOverflows, `${where}: the bar overflows its container`).toBe(false); +} + +test.describe("ModeNav density", () => { + for (const { width, expected, slots } of [ + { width: BAND_3_PX - 1, expected: "collapsed" as const, slots: 0 }, + { width: BAND_3_PX, expected: "bar" as const, slots: 3 }, + { width: BAND_3_PX + 1, expected: "bar" as const, slots: 3 }, + { width: BAND_4_PX - 1, expected: "bar" as const, slots: 3 }, + { width: BAND_4_PX, expected: "bar" as const, slots: 4 }, + { width: BAND_4_PX + 1, expected: "bar" as const, slots: 4 }, + ]) { + test(`shows every label in full at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await gotoTherapySearch(page); + + // Poll the density itself rather than asserting one sample: the band is + // re-evaluated as the header settles, and a single early read is a coin + // flip. A band that never arrives still fails, on the timeout. + await expect.poll(async () => (await readNav(page)).state, { timeout: 10_000 }).toBe(expected); + + const nav = await readNav(page); + expect(nav.labels).toHaveLength(slots); + expectNoClippedLabels(nav, `${width}px`); + }); + } + + test("falls back to the collapsed control rather than clipping at 200% text", async ({ page }) => { + // The `rem` unit is the whole mechanism the density bands rest on: raising + // the browser or OS text size grows the root font, so the container + // measures FEWER rem and the bar steps down exactly when its labels would + // stop fitting (WCAG 1.4.4 Resize Text, 1.4.10 Reflow). A px threshold + // would ignore the request and clip. The style has to land before the + // container query is evaluated, hence document_start rather than a later + // addStyleTag. + await page.addInitScript(() => { + document.addEventListener("DOMContentLoaded", () => { + const style = document.createElement("style"); + style.textContent = "html { font-size: 32px !important; }"; + document.head.appendChild(style); + }); + }); + await page.setViewportSize({ width: 390, height: 844 }); + await gotoTherapySearch(page); + + // 390px at a 32px root is 12.19rem — below the 22rem bar band. + await expect.poll(async () => (await readNav(page)).state, { timeout: 10_000 }).toBe("collapsed"); + + const nav = await readNav(page); + expectNoClippedLabels(nav, "390px at 200% text"); + // The page itself must not gain a sideways scrollbar from the reflow. + expect( + await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1), + ).toBe(false); + }); +});