Skip to content
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ 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/;

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
Expand Down
52 changes: 35 additions & 17 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -2384,20 +2408,14 @@ td,
}
}

@container mode-nav (min-width: 34rem) {
@container mode-nav (min-width: 42rem) {
.mode-nav__slot[data-band="5"] {
display: flex;
}

.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) */
Expand Down
30 changes: 24 additions & 6 deletions tests/mode-nav-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
);
});
Expand All @@ -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"]);
});
});

Expand Down
146 changes: 146 additions & 0 deletions tests/ui-mode-nav-density.spec.ts
Original file line number Diff line number Diff line change
@@ -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<NavState> {
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);
});
});
Loading