diff --git a/tests/dashboard-scroll-padding.test.ts b/tests/dashboard-scroll-padding.test.ts new file mode 100644 index 00000000..ad30b204 --- /dev/null +++ b/tests/dashboard-scroll-padding.test.ts @@ -0,0 +1,62 @@ +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"); +} + +// 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(mainContentTag).not.toBe(""); + expect(mainContentTag).toContain("overflow-y-auto"); + }); + + 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(mainContentTag).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 9cd70a0b..f9ba9e32 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);