From b588253501f420e8e2478c34ec4ca0c64587ca65 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:47:18 +0800 Subject: [PATCH 1/2] test(a11y): lock in reduced-motion scroll + focus-not-obscured regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard the two accessibility fixes shipped in #1036 against regression: - ANIM-01 (WCAG 2.3.3): a live Chromium test intercepts the behaviour argument every scripted scrollTo/scrollIntoView receives, then triggers a known scroll ("New chat"). Reduced motion must produce instant "auto" jumps; no-preference must animate ("smooth") — verifying resolveScrollBehavior() is threaded through the call sites, not just unit-tested in isolation. - A11Y-FOCUS-01 (WCAG 2.4.11): a deterministic source pin asserts #main-content reserves scroll-padding-bottom in both mobile dock branches (plus the answer scroll-padding-top). The reservation only renders in app states impractical to reach reliably in a browser test, so it is pinned at source — the repo's established pattern for phone scroll-geometry invariants. Verified: typecheck, lint, format:check, full unit+jsdom suite (348 files / 3108 passed / 0 failed), and both targeted tests green against the local server. Co-Authored-By: Claude Opus 4.8 --- tests/dashboard-scroll-padding.test.ts | 45 +++++++++++++++++++++ tests/ui-accessibility.spec.ts | 56 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 tests/dashboard-scroll-padding.test.ts diff --git a/tests/dashboard-scroll-padding.test.ts b/tests/dashboard-scroll-padding.test.ts new file mode 100644 index 000000000..632954d64 --- /dev/null +++ b/tests/dashboard-scroll-padding.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +// A11Y-FOCUS-01 (WCAG 2.4.11 — focus not obscured). On phones the composer docks +// over the #main-content scrollport, so the scroll container must reserve +// scroll-padding-bottom = the dock height; otherwise the browser scrolls a +// below-fold Tab target to rest underneath the fixed dock. This reservation only +// renders in specific app states (answer-with-content, and every non-answer view) +// that are impractical to reach deterministically in a browser test, so it is +// pinned at the source instead — the repo's established pattern for phone +// scroll-geometry invariants (see mobile-interaction-regressions.test.ts). The +// reduced-motion scroll behaviour (ANIM-01) is covered live in ui-accessibility.spec.ts. + +function source(relativePath: string): string { + return readFileSync(resolve(process.cwd(), relativePath), "utf8"); +} + +describe("dashboard scroll-padding keeps keyboard focus clear of fixed chrome", () => { + const dashboard = source("src/components/ClinicalDashboard.tsx"); + + it("keeps the reservation on the #main-content scroll container", () => { + // The scrollport (overflow-y-auto) and the scroll-padding must be the same + // element — padding on a non-scrolling ancestor would not move focus targets. + expect(dashboard).toMatch(/id="main-content"[\s\S]*?overflow-y-auto/); + }); + + it("reserves scroll-padding-bottom whenever the mobile composer docks", () => { + // Both dock branches (answer-with-content and non-answer views) reserve the + // dock clearance; a bare pb reservation is not enough because focus scrolling + // honours scroll-padding, not padding. + const reserve = "max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)]"; + const occurrences = dashboard.split(reserve).length - 1; + expect(occurrences).toBeGreaterThanOrEqual(2); + // Each reservation is paired with the matching content padding. + expect(dashboard).toContain("max-sm:pb-[var(--mobile-composer-reserve)] " + reserve); + }); + + it("reserves scroll-padding-top under the absolute answer header", () => { + // The glass answer header is absolute over the scrollport, so focusing a field + // near the top must clear the header too. + expect(dashboard).toContain("[scroll-padding-top:calc(4.5rem+max(0.5rem,env(safe-area-inset-top)))]"); + }); +}); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 9cd70a0b2..f9ba9e32c 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -195,6 +195,62 @@ test.describe("Clinical KB accessibility coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("scripted scrolls honour the reduced-motion preference", async ({ page }) => { + // ANIM-01 (WCAG 2.3.3): the scripted scrollTo/scrollIntoView calls resolve their + // behaviour through resolveScrollBehavior(), so a reduced-motion preference must + // turn them into instant ("auto") jumps instead of smooth animations. Record the + // behaviour argument every scroll call receives, then trigger a known one. + await page.addInitScript(() => { + const store: string[] = []; + (window as unknown as { __scrollBehaviors: string[] }).__scrollBehaviors = store; + const record = (behavior?: ScrollBehavior) => store.push(behavior ?? "auto"); + const origScrollTo = Element.prototype.scrollTo; + Element.prototype.scrollTo = function scrollTo(this: Element, ...args: unknown[]) { + const options = args[0]; + if (options && typeof options === "object" && "behavior" in options) { + record((options as ScrollToOptions).behavior); + } + return (origScrollTo as (...callArgs: unknown[]) => void).apply(this, args); + } as typeof Element.prototype.scrollTo; + const origScrollIntoView = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = function scrollIntoView(this: Element, ...args: unknown[]) { + const options = args[0]; + if (options && typeof options === "object" && "behavior" in options) { + record((options as ScrollIntoViewOptions).behavior); + } + return (origScrollIntoView as (...callArgs: unknown[]) => void).apply(this, args); + } as typeof Element.prototype.scrollIntoView; + }); + + const readBehaviours = () => + page.evaluate(() => (window as unknown as { __scrollBehaviors: string[] }).__scrollBehaviors); + const resetBehaviours = () => + page.evaluate(() => { + (window as unknown as { __scrollBehaviors: string[] }).__scrollBehaviors.length = 0; + }); + + await page.setViewportSize({ width: 1280, height: 800 }); + await mockMinimalDashboardApi(page); + + // Reduced motion → every scripted scroll must be an instant "auto" jump. + await page.emulateMedia({ reducedMotion: "reduce" }); + await gotoApp(page); + await expectDashboardUsable(page); + await resetBehaviours(); + await page.getByRole("button", { name: "New chat" }).first().click(); + await expect.poll(readBehaviours).not.toHaveLength(0); + const reduced = await readBehaviours(); + expect(reduced, "reduced motion must not animate scripted scrolls").not.toContain("smooth"); + expect(reduced.every((behaviour) => behaviour === "auto")).toBe(true); + + // No preference → the same action animates smoothly. + await page.emulateMedia({ reducedMotion: "no-preference" }); + await resetBehaviours(); + await page.getByRole("button", { name: "New chat" }).first().click(); + await expect.poll(readBehaviours).not.toHaveLength(0); + expect(await readBehaviours(), "no-preference should animate scripted scrolls").toContain("smooth"); + }); + test("mode menu dismisses when keyboard focus leaves its wrapper", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await mockMinimalDashboardApi(page); From 15f1986aaf7597f823cb02162b0f2b225d1d9e85 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 21:22:54 +0000 Subject: [PATCH 2/2] test(a11y): scope scroll-padding guard to #main-content and require both dock branches Address the review findings on the A11Y-FOCUS-01 source pin: - Bind every assertion to the extracted `
` opening tag instead of counting source-wide occurrences, so a reservation class that drifts onto a child or a non-scrolling wrapper now fails the guard (a `>` inside a className comment means the tag is read up to its line-final `>`). - Require the *paired* `pb` + `scroll-padding-bottom` reservation in both dock branches (>=2), so one branch losing its content padding no longer passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LRZ1EyBZW1ADXrZvMxEEsC --- tests/dashboard-scroll-padding.test.ts | 39 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/dashboard-scroll-padding.test.ts b/tests/dashboard-scroll-padding.test.ts index 632954d64..ad30b2041 100644 --- a/tests/dashboard-scroll-padding.test.ts +++ b/tests/dashboard-scroll-padding.test.ts @@ -17,29 +17,46 @@ function source(relativePath: string): string { return readFileSync(resolve(process.cwd(), relativePath), "utf8"); } +// Extract the
opening tag, so every assertion binds to +// the real scrollport rather than to source-wide occurrences — a class that +// silently drifts onto a child or a non-scrolling wrapper must fail the guard. +// The className holds a `>` inside a comment ("
reserves…"), so we cannot +// stop at the first `>`; the JSX opening tag ends at the first line whose only +// non-space content is `>`. +function mainContentOpeningTag(dashboardSource: string): string { + const idIndex = dashboardSource.indexOf('id="main-content"'); + if (idIndex < 0) return ""; + const openIndex = dashboardSource.lastIndexOf("/); + return closeOffset < 0 ? "" : dashboardSource.slice(openIndex, openIndex + closeOffset); +} + describe("dashboard scroll-padding keeps keyboard focus clear of fixed chrome", () => { const dashboard = source("src/components/ClinicalDashboard.tsx"); + const mainContentTag = mainContentOpeningTag(dashboard); it("keeps the reservation on the #main-content scroll container", () => { // The scrollport (overflow-y-auto) and the scroll-padding must be the same // element — padding on a non-scrolling ancestor would not move focus targets. - expect(dashboard).toMatch(/id="main-content"[\s\S]*?overflow-y-auto/); + expect(mainContentTag).not.toBe(""); + expect(mainContentTag).toContain("overflow-y-auto"); }); - it("reserves scroll-padding-bottom whenever the mobile composer docks", () => { - // Both dock branches (answer-with-content and non-answer views) reserve the - // dock clearance; a bare pb reservation is not enough because focus scrolling - // honours scroll-padding, not padding. - const reserve = "max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)]"; - const occurrences = dashboard.split(reserve).length - 1; - expect(occurrences).toBeGreaterThanOrEqual(2); - // Each reservation is paired with the matching content padding. - expect(dashboard).toContain("max-sm:pb-[var(--mobile-composer-reserve)] " + reserve); + it("reserves scroll-padding-bottom in both mobile composer dock branches", () => { + // Both dock branches (answer-with-content and non-answer views) must pair the + // content padding with the scroll-padding reservation — focus scrolling honours + // scroll-padding, not padding, so a bare pb is not enough. Require the paired + // reservation in both branches; a single bare occurrence can no longer satisfy + // the guard. + const pairedReserve = + "max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)]"; + expect(mainContentTag.split(pairedReserve).length - 1).toBeGreaterThanOrEqual(2); }); it("reserves scroll-padding-top under the absolute answer header", () => { // The glass answer header is absolute over the scrollport, so focusing a field // near the top must clear the header too. - expect(dashboard).toContain("[scroll-padding-top:calc(4.5rem+max(0.5rem,env(safe-area-inset-top)))]"); + expect(mainContentTag).toContain("[scroll-padding-top:calc(4.5rem+max(0.5rem,env(safe-area-inset-top)))]"); }); });