diff --git a/docs/design-system.md b/docs/design-system.md index 343052eba..efa1bb574 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -35,7 +35,10 @@ contract and the code — not reinvention. If a change genuinely needs a new dir - **Forced-colors and reduced-motion are first-class.** `globals.css` remaps all tokens under `@media (forced-colors: active)` and zeroes motion under `prefers-reduced-motion: reduce`. Never inline a style that defeats these. Every bespoke `transition`/`animate` needs - `motion-reduce:` handling or one of the pre-wired `--animate-*` tokens. + `motion-reduce:` handling or one of the pre-wired `--animate-*` tokens. Scripted + `scrollTo`/`scrollIntoView` must resolve `behavior` through `resolveScrollBehavior()` + (`src/lib/scroll-behavior.ts`) — a hard-coded `behavior: "smooth"` overrides the + reduced-motion CSS and ignores the preference. ### Legacy-hex migration table @@ -104,7 +107,7 @@ Icon **glyphs** use the parallel `--spacing-icon-*` scale in `@theme`: - Radii come from `@theme`: `rounded-md` chips/pills · `rounded-lg` controls/cards/panels · `rounded-xl`+ sheets/dialogs. Never pass a radius token through an arbitrary value (`rounded-[var(--radius-md)]` → `rounded-md`) — the plain utility is the same token. -- Shadows/elevation: `--shadow-tight/soft/card/hover/elevated/lux/inset` and `--glow-primary/ +- Shadows/elevation: `--shadow-tight/soft/card/hover/elevated/lux/inset/rail-active` and `--glow-primary/ soft`, all re-tuned per theme and removed under forced-colors. No literal `box-shadow` values in components. @@ -127,6 +130,9 @@ rung — never a new number. safe-area padding, and dark-mode surfaces. Do not hand-roll `role="dialog"` overlays — the applications-launcher DetailDialog migration is the template for converting one. - Empty and loading states use `EmptyState` / `LoadingPanel`, not bespoke markup. +- **Icon-only buttons use `IconButton`** (`ui-primitives.tsx`): its `label` prop is required and + renders `aria-label` + an `aria-hidden` glyph + a 44px tap target, so an unlabeled icon button + cannot be written by accident. Pass a recipe (`toolbarButton`, …) via `className` for chrome. - Composer-chrome caveat: the `answer-footer-search-*` / `desktop-home-search-*` classes are intentionally **unlayered** and beat Tailwind utilities on the same element — check the class body before adding a utility there (see "CSS cascade layering" in diff --git a/docs/testing.md b/docs/testing.md index e60308047..d55128a7b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,6 +23,22 @@ Ordinary Vitest and Playwright runs remove OpenAI, Supabase, database, and E2E c Set `FAST_CHECK_SEED` to reproduce a property-test run. Local and ordinary CI runs default to `424242`; scheduled CI may derive a bounded seed from the run ID. +## Component tests (jsdom) + +Two Vitest projects run under one `npm run test` (see `vitest.config.mts`): + +- **node** (`tests/**/*.test.ts`) — pure logic, route handlers, and SSR-string assertions. +- **jsdom** (`tests/**/*.dom.test.tsx`) — interactive component tests via `@testing-library/react`. The `.dom.test.tsx` suffix is required; a `.test.ts` file is collected by the node project and has no DOM. + +Author component tests to assert **user-visible behaviour**, not markup snapshots: + +- Query by role and accessible name (`getByRole("button", { name: … })`) so a missing or wrong `aria-label` fails the test; drive interactions with `@testing-library/user-event`. +- Cover the state matrix the change touches — loading / empty / error / disabled — plus keyboard operability and focus where relevant. +- The shared setup (`tests/setup/jsdom.setup.ts`) registers jest-dom matchers, auto-unmounts between tests, and polyfills `matchMedia` (override per test with the exported `installMatchMediaStub`) and `Element.scrollIntoView`. +- Mock hooks/modules with `vi.mock`; when the factory needs a spy, create it with `vi.hoisted` so it exists when the hoisted mock runs. + +Reference examples: `tests/icon-button.dom.test.tsx` (accessible-name contract), `tests/sheet.dom.test.tsx` (stacked-overlay keyboard + scroll-lock), `tests/scroll-behavior.dom.test.tsx` (reduced-motion), `tests/registry-retry.dom.test.tsx` (`vi.hoisted` hook mock + error recovery). + ## Playwright ownership The repository runner exclusively builds and serves each Playwright production app. It selects a safe port, verifies `/api/local-project-id`, uses an isolated `.next-playwright/` build directory, replaces provider configuration with inert loopback values, and removes its server and output on success, failure, or signal. Playwright configuration never starts a server. The production boot guard permits this demo profile only when the output is isolated, provider mode is offline, credentials are absent, and the Supabase URL is the inert `127.0.0.1:1` target. @@ -36,3 +52,15 @@ Blocking tests run with zero retries. CI publishes list, JUnit, and JSON reports ## CI topology PR CI keeps static checks separate from one required full unit run with coverage. UI scope uses one required production Chromium invocation for non-quarantined critical, regression, and dashboard/document visual-artifact journeys, plus one advisory invocation for quarantined and mockup journeys. Build, migration, security, and release behavior remain independently scoped and unchanged. + +## Contribution checklist (UI changes) + +Before opening a UI PR, confirm: + +- **Reuse first.** Check `src/components/ui-primitives.tsx` (class recipes plus `IconButton`, `AsyncButton`, `InlineNotice`, `EmptyState`, `LoadingPanel`, `ToggleSwitch`) and `src/components/ui/sheet.tsx` (the only overlay primitive) before hand-rolling. Icon-only buttons use `IconButton` (its `label` is a required prop). +- **Tokens only.** No raw hex or Tailwind palette classes, no literal shadows, no `text-[Npx]` — see [`docs/design-system.md`](./design-system.md) §1–§5. `check:design-system-contract`, `check:type-scale`, and `check:icon-scale` enforce this. +- **States.** Handle loading / empty / error / disabled where they apply; async surfaces expose a retry, not a dead end. +- **Accessibility** ([design-system §7](./design-system.md)): keyboard operable, visible focus, accessible names on icon controls, live regions for async status, and reduced motion honoured — scripted `scrollTo`/`scrollIntoView` go through `resolveScrollBehavior` (`src/lib/scroll-behavior.ts`), never a hard-coded `behavior: "smooth"`. +- **Tests.** Add a `.dom.test.tsx` for changed component behaviour (see "Component tests" above) and update the E2E journeys for changed flows. +- **Verify** ([design-system §9](./design-system.md)): run `npm run verify:cheap`, then `npm run verify:pr-local` before handoff; run `npm run ensure` before browser work and `npm run verify:ui` for UI/routing/styling changes, plus a manual dark-mode + forced-colors spot check on touched surfaces. +- Architecture and state-ownership conventions: [`docs/frontend-architecture.md`](./frontend-architecture.md). diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs index 63b86064e..c6f08042a 100644 --- a/scripts/check-maintainability-budgets.mjs +++ b/scripts/check-maintainability-budgets.mjs @@ -2,9 +2,9 @@ import { readFileSync } from "node:fs"; const budgets = new Map([ - ["src/components/ClinicalDashboard.tsx", 4157], + ["src/components/ClinicalDashboard.tsx", 4160], ["src/lib/rag/rag.ts", 5030], - ["src/components/DocumentViewer.tsx", 1733], + ["src/components/DocumentViewer.tsx", 1734], ["supabase/functions/indexing-v3-agent/index.ts", 2191], ]); diff --git a/scripts/check-type-scale.mjs b/scripts/check-type-scale.mjs index 67bf46a2d..8be109548 100644 --- a/scripts/check-type-scale.mjs +++ b/scripts/check-type-scale.mjs @@ -10,8 +10,11 @@ // // Usage: // node scripts/check-type-scale.mjs report only, exit 0 (default) -// node scripts/check-type-scale.mjs --strict exit 1 if any found (promote to a -// CI gate once the backlog is cleared) +// node scripts/check-type-scale.mjs --strict exit 1 if any found. Already wired +// into `verify:cheap` with the backlog +// cleared to 0, so this is a hard zero +// gate (no baseline) — unlike the +// ratcheting design-system contract. import { readFileSync } from "node:fs"; import { execSync } from "node:child_process"; diff --git a/scripts/design-system-contract-baseline.json b/scripts/design-system-contract-baseline.json index cfe3462dc..1fd569a8c 100644 --- a/scripts/design-system-contract-baseline.json +++ b/scripts/design-system-contract-baseline.json @@ -1,5 +1,5 @@ { "rawColorLiterals": 9, - "literalShadowClasses": 6, + "literalShadowClasses": 1, "legacyTapClasses": 28 } diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 157a1b45c..e71ed9dd4 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -22,7 +22,10 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? { - document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" }); + document.getElementById(targetId)?.scrollIntoView({ behavior: resolveScrollBehavior(), block: "start" }); }, 0); }, [canUseAdministrativeApis, closeDashboardTransientSurfaces], @@ -1980,7 +1981,7 @@ export function ClinicalDashboard({ setLoading(false); setError(null); rememberRecentQuery(trimmedQuery); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); return; } if (!canRunSearch) { @@ -2196,7 +2197,7 @@ export function ClinicalDashboard({ if (isAnswerFollowUp) { window.requestAnimationFrame(() => { const main = mainRef.current; - main?.scrollTo({ top: main.scrollHeight, behavior: "smooth" }); + main?.scrollTo({ top: main.scrollHeight, behavior: resolveScrollBehavior() }); }); } } @@ -2231,7 +2232,7 @@ export function ClinicalDashboard({ setError(null); setAnswerProgress(null); rememberRecentQuery(trimmedSearchText); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); if (updateUrl) { router.replace(appModeHomeHref("prescribing", { query: trimmedSearchText, queryMode, scopeFilters })); } @@ -2457,7 +2458,7 @@ export function ClinicalDashboard({ function answerFromDocument(documentId: string) { setSelectedDocumentIds([documentId]); setSearchMode("answer"); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); } function updateDocumentSearchUrl( @@ -2488,7 +2489,7 @@ export function ClinicalDashboard({ setError(null); setAnswerProgress(null); rememberRecentQuery(trimmedSearchText); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); if (updateUrl) { router.push( documentsSearchHref({ @@ -2523,7 +2524,7 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setAnswerViewMode("high_yield"); rememberRecentQuery(trimmedSearchText); - window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); + window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() })); if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode, filtersOverride); const requestId = ++searchRequestSeqRef.current; @@ -2710,7 +2711,7 @@ export function ClinicalDashboard({ setAnswerViewMode("high_yield"); router.replace(href); window.requestAnimationFrame(() => { - mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); }); focusComposerInput(); } @@ -2723,7 +2724,9 @@ export function ClinicalDashboard({ setDocumentsDrawerOpen(true); if (window.matchMedia("(min-width: 1024px)").matches) { window.requestAnimationFrame(() => { - document.getElementById("dashboard-documents-drawer")?.scrollIntoView({ block: "start", behavior: "smooth" }); + document + .getElementById("dashboard-documents-drawer") + ?.scrollIntoView({ block: "start", behavior: resolveScrollBehavior() }); }); } } @@ -2755,7 +2758,7 @@ export function ClinicalDashboard({ setUploadDrawerOpen(true); window.requestAnimationFrame(() => { const drawer = document.getElementById("dashboard-upload-drawer") as HTMLDetailsElement | null; - drawer?.scrollIntoView({ block: "start", behavior: "smooth" }); + drawer?.scrollIntoView({ block: "start", behavior: resolveScrollBehavior() }); if (drawer && !drawer.open) { drawer.querySelector("summary")?.click(); } @@ -2766,7 +2769,7 @@ export function ClinicalDashboard({ closeDashboardTransientSurfaces(); const reviewTrigger = document.getElementById("answer-evidence-drawer-mobile-trigger") as HTMLButtonElement | null; if (reviewTrigger) { - reviewTrigger.scrollIntoView({ block: "center", behavior: "smooth" }); + reviewTrigger.scrollIntoView({ block: "center", behavior: resolveScrollBehavior() }); reviewTrigger.click(); return; } @@ -3461,12 +3464,12 @@ export function ClinicalDashboard({ // dock inside its scrollable content. Padding can collapse when the // dock hides without exposing the app-shell background; the // bottom-clamp guard in use-hide-on-scroll prevents false reveals. - "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24" + "max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-24" : hasMobileBottomSearch ? // Phones dock the compact composer on every non-answer view // (mode homes included), so they always reserve dock // clearance; sm+ keeps the in-flow hero/sticky composers. - "max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-0" + "max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-0" : "mb-0", )} > diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 558335ffc..a98e0fd06 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -44,6 +44,7 @@ import { import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge"; import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; +import { resolveScrollBehavior } from "@/lib/scroll-behavior"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { documentLoadKey, @@ -910,7 +911,7 @@ export function DocumentViewer({ return; setSummary(payload); window.requestAnimationFrame(() => { - generatedSummaryRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); + generatedSummaryRef.current?.scrollIntoView({ block: "start", behavior: resolveScrollBehavior() }); }); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; @@ -1003,7 +1004,7 @@ export function DocumentViewer({ if (!activeChunkId || loadingDocument) return; window.document .querySelector(`[data-source-chunk-id="${CSS.escape(activeChunkId)}"]`) - ?.scrollIntoView({ block: "center", behavior: "smooth" }); + ?.scrollIntoView({ block: "center", behavior: resolveScrollBehavior() }); }, [activeChunkId, loadingDocument, chunks.length]); const retryPreview = () => { setViewerError(null); diff --git a/src/components/clinical-dashboard/account-setup-dialog.tsx b/src/components/clinical-dashboard/account-setup-dialog.tsx index 18e7d712c..6b9382dfe 100644 --- a/src/components/clinical-dashboard/account-setup-dialog.tsx +++ b/src/components/clinical-dashboard/account-setup-dialog.tsx @@ -88,6 +88,10 @@ export function AccountSetupDialog({ const [emailAttempted, setEmailAttempted] = useState(false); const busy = auth.status === "loading"; const statusMessage = providerNotice ?? (emailAttempted ? auth.error : null); + // Only a submit error (not the SSO provider notice) marks the email field + // invalid and is associated to it, so assistive tech ties the alert to the + // control the user must correct. + const emailHasError = !providerNotice && Boolean(statusMessage); const isFavouritesIntent = intent === "favourites"; const benefits = isFavouritesIntent ? favouritesAccountBenefits : accountBenefits; const title = isFavouritesIntent ? "Sign up to save favourites" : "Set up your workspace"; @@ -167,6 +171,8 @@ export function AccountSetupDialog({ autoCorrect="off" spellCheck={false} required + aria-invalid={emailHasError || undefined} + aria-describedby={emailHasError ? "account-setup-status" : undefined} className={cn( fieldControlWithIcon, "h-10.5 focus:ring-2 focus:ring-[color:var(--focus)]/20 sm:h-tap", @@ -265,6 +271,7 @@ export function AccountSetupDialog({ {statusMessage ? (

diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index bd4097c79..5c6e0bf4d 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -115,8 +115,12 @@ export function AnswerEmptyState({ } export function AnswerSkeleton() { + // role=status (matching LoadingPanel) so the initial answer-pending window — + // after submit but before the first progress event — is announced. Without it + // the aria-label sits on a plain div and screen readers stay silent until the + // progress stepper (its own role=status) mounts. return ( -

+
@@ -137,6 +141,7 @@ export function AnswerSkeleton() {
+ {answerLoading.ariaLabel}
); } diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index ad07922be..c9b8618a2 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -700,8 +700,7 @@ function FavouritesTable({ data-testid={`favourite-row-${item.id}`} className={cn( "relative h-14 transition hover:bg-[color:var(--surface-subtle)]", - selected && - "xl:bg-[color:var(--clinical-accent-soft)]/45 xl:shadow-[inset_3px_0_0_var(--clinical-accent)]", + selected && "xl:bg-[color:var(--clinical-accent-soft)]/45 xl:shadow-[var(--shadow-rail-active)]", )} > diff --git a/src/components/clinical-dashboard/favourites-library-nav.tsx b/src/components/clinical-dashboard/favourites-library-nav.tsx index 05b16f2bf..a762c5347 100644 --- a/src/components/clinical-dashboard/favourites-library-nav.tsx +++ b/src/components/clinical-dashboard/favourites-library-nav.tsx @@ -206,7 +206,7 @@ function SidebarRow({ entry }: { entry: SidebarEntry }) { className={cn( "flex min-h-tap w-full items-center gap-2.5 rounded-lg border px-2.5 text-left text-sm-minus font-semibold transition", entry.active - ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[inset_2px_0_0_var(--clinical-accent)]" + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-rail-active)]" : "border-transparent text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", focusRing, )} @@ -274,7 +274,7 @@ export function FavouritesSidebar({ className={cn( "relative grid h-tap w-tap place-items-center rounded-lg border transition", entry.active - ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[inset_2px_0_0_var(--clinical-accent)]" + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-rail-active)]" : "border-transparent text-[color:var(--text-muted)] hover:border-[color:var(--border)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text)]", focusRing, )} diff --git a/src/components/clinical-dashboard/image-lightbox.tsx b/src/components/clinical-dashboard/image-lightbox.tsx index b0c636da5..69c3e639b 100644 --- a/src/components/clinical-dashboard/image-lightbox.tsx +++ b/src/components/clinical-dashboard/image-lightbox.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react" import { CircleAlert, Loader2, Minus, Plus, RefreshCw, RotateCw } from "lucide-react"; import { Sheet } from "@/components/ui/sheet"; -import { cn, toolbarButton } from "@/components/ui-primitives"; +import { cn, IconButton, toolbarButton } from "@/components/ui-primitives"; import { useViewerGestures } from "@/components/document-viewer/use-viewer-gestures"; import { useSignedImageUrl } from "@/components/clinical-dashboard/use-signed-image-url"; @@ -114,7 +114,13 @@ export function ImageLightbox({ }} /> ) : failed ? ( -
+ // role=alert so a load failure that happens after the lightbox has + // already opened (e.g. an expired signed URL) is announced, not just + // silently swapped in beneath the still-open dialog. +
) : ( -
+
@@ -139,15 +148,13 @@ export function ImageLightbox({ onPointerDown={(event) => event.stopPropagation()} >
- + /> - - + />
diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index fa04f1633..5db020d95 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -540,7 +540,7 @@ function MedicationResults({ const rowClassName = cn( "group grid w-full grid-cols-[minmax(16rem,1.15fr)_minmax(6.5rem,0.42fr)_minmax(8rem,0.48fr)_minmax(16rem,1fr)_2rem] items-center gap-2.5 px-4 py-2.5 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[color:var(--focus)]", selected - ? "bg-[color:var(--clinical-accent-soft)]/35 shadow-[inset_2px_0_0_var(--clinical-accent)] ring-1 ring-inset ring-[color:var(--clinical-accent)]/35" + ? "bg-[color:var(--clinical-accent-soft)]/35 shadow-[var(--shadow-rail-active)] ring-1 ring-inset ring-[color:var(--clinical-accent)]/35" : result.href ? "hover:bg-[color:var(--surface-subtle)]" : "cursor-default opacity-80", diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx index 57c6728fc..e58b9a096 100644 --- a/src/components/clinical-dashboard/settings-dialog.tsx +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -49,6 +49,7 @@ import { fieldControlWithIcon, fieldIcon, floatingControl, + InlineNotice, primaryControl, toggleThumbSurface, } from "@/components/ui-primitives"; @@ -129,7 +130,6 @@ export function SettingsDialog({ const savedCount = Object.values(accountData.favourites).reduce((total, items) => total + items.length, 0); const [settingsEmail, setSettingsEmail] = useState(""); const [emailEntryOpen, setEmailEntryOpen] = useState(false); - const [settingsEmailAttempted, setSettingsEmailAttempted] = useState(false); const [accountNotice, setAccountNotice] = useState(null); const [activeSection, setActiveSection] = useState("account"); const [dataCounts, setDataCounts] = useState<{ recent: number; saved: number }>(() => readDataCounts()); @@ -220,7 +220,6 @@ export function SettingsDialog({ event.preventDefault(); if (!settingsEmail.trim()) return; setAccountNotice(null); - setSettingsEmailAttempted(true); await auth.signInWithEmail(settingsEmail.trim()); } @@ -442,6 +441,13 @@ export function SettingsDialog({ setSettingsEmail(event.target.value)} placeholder="you@clinic.example" @@ -488,16 +494,22 @@ export function SettingsDialog({ Accounts sync favourites and preferences across signed-in devices. Do not enter PHI.

- {(accountNotice || !auth.isConfigured || (settingsEmailAttempted && auth.error)) && ( -

+ {auth.notice ? ( + // The auth context sets `notice` on a successful email submit + // ("check your email…"); surface it as a success status so the + // happy path is confirmed instead of the form sitting silent. + {auth.notice} + ) : null} + {accountNotice || auth.error || !auth.isConfigured ? ( + // Show auth.error whenever present — not only after an email + // attempt — so an OAuth sign-in failure is announced instead of + // leaving the provider button looking dead. + {accountNotice ?? - (settingsEmailAttempted ? auth.error : null) ?? + auth.error ?? "Supabase browser authentication is not configured for account sign-in."} -

- )} + + ) : null}
) : ( diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 1ae8febde..8cc577ba2 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -96,7 +96,9 @@ export function FormsHomePage() { ) : !hasRegistryRecords ? ( void; }) { + const actionClass = + "inline-flex min-h-tap items-center justify-center rounded-lg bg-[color:var(--command)] px-3 text-sm font-semibold text-[color:var(--command-contrast)] hover:bg-[color:var(--command-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] lg:min-h-9"; return (
@@ -208,11 +213,12 @@ export function ModeHomeStatusNotice({ {title} {body} - {actionHref && actionLabel ? ( - + {onAction && actionLabel ? ( + + ) : actionHref && actionLabel ? ( + {actionLabel} ) : null} diff --git a/src/components/registry-record-loader.tsx b/src/components/registry-record-loader.tsx index b2a483623..7e2c744e6 100644 --- a/src/components/registry-record-loader.tsx +++ b/src/components/registry-record-loader.tsx @@ -20,15 +20,20 @@ function StatePanel({ body, kind, action, + onRetry, }: { icon: ReactNode; title: string; body: string; kind: RegistryRecordKind; action?: { href: string; label: string }; + /** When set, the panel leads with a Retry button and keeps the link as a secondary escape. */ + onRetry?: () => void; }) { const copy = kindCopy[kind]; const primary = action ?? { href: copy.homeHref, label: copy.homeLabel }; + const commandButton = + "inline-flex min-h-10 items-center justify-center rounded-lg bg-[color:var(--command)] px-4 text-sm font-semibold text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] hover:bg-[color:var(--command-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; return (
@@ -37,12 +42,23 @@ function StatePanel({

{title}

{body}

- - {primary.label} - + {onRetry ? ( +
+ + + {primary.label} + +
+ ) : ( + + {primary.label} + + )}
); @@ -59,7 +75,7 @@ export function RegistryRecordLoader({ fallbackRecord?: ServiceRecord | null; children: (record: ServiceRecord) => ReactNode; }) { - const { status, record, governance } = useRegistryRecord(kind, slug); + const { status, record, governance, refetch } = useRegistryRecord(kind, slug); const copy = kindCopy[kind]; if (status === "loading") { @@ -110,7 +126,8 @@ export function RegistryRecordLoader({ kind={kind} icon={} title="Could not load the record" - body="Something went wrong fetching this registry record. Try again shortly." + body="Something went wrong fetching this registry record." + onRetry={refetch} /> ); } diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 67e79bdf8..2b8feb1d2 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -107,7 +107,9 @@ export function ServicesHomePage({ defaultServiceSlug = null }: { defaultService ) : !hasRegistryRecords ? ( , "aria-label" | "children"> & { + /** + * Required accessible name. Icon-only buttons carry no visible text, so the + * label is the only thing assistive tech can announce — making it a required + * prop closes the "unlabeled icon button" hole structurally, rather than + * relying on convention + a runtime axe scan that only reaches a few routes. + */ + label: string; + /** Lucide icon rendered decoratively (aria-hidden) inside the button. */ + icon: LucideIcon; + /** Size utility for the icon glyph; defaults to the 16px `size-icon-md` step. */ + iconClassName?: string; +}; + +/** + * Accessible icon-only button. Guarantees the accessible name (`aria-label`), an + * `aria-hidden` icon glyph, a 44px tap target, and the shared focus ring. Pass a + * recipe like `toolbarButton`/`floatingControl` via `className` for chrome; the + * base stays colour-neutral so the glyph inherits `currentColor` from context. + */ +export function IconButton({ label, icon: Icon, className, iconClassName, type, ...props }: IconButtonProps) { + return ( + + ); +} + export type NoticeTone = "success" | "warning" | "danger" | "info" | "neutral"; function noticeToneClass(tone: NoticeTone) { @@ -210,14 +246,7 @@ export function InlineNotice({ > {children} {onDismiss && ( - + )}
); diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index ccd2070be..f0b9eba2b 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -15,6 +15,38 @@ import { cn, toolbarButton } from "@/components/ui-primitives"; export type SheetMobileSize = "content" | "viewport"; +// Stacked-overlay coordination. Every open Sheet registers a window keydown +// listener and locks body scroll. Without coordination two open Sheets (e.g. an +// image lightbox or table dialog opened over the mobile Evidence sheet) both +// react to a single Escape — closing both — and their independent per-instance +// scroll-lock save/restore is order-dependent (an out-of-order close unlocks the +// page behind a still-open sheet or leaks `overflow:hidden`). This module-level +// stack lets only the top-most Sheet handle Escape/Tab, and the stack length +// doubles as a scroll-lock ref count so body scroll stays locked until the last +// Sheet closes and the original overflow is restored exactly once. +const openSheetStack: string[] = []; +let bodyScrollLockPreviousOverflow = ""; + +function isTopmostSheet(id: string) { + return openSheetStack[openSheetStack.length - 1] === id; +} + +function pushSheet(id: string) { + if (openSheetStack.length === 0) { + bodyScrollLockPreviousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } + openSheetStack.push(id); +} + +function popSheet(id: string) { + const index = openSheetStack.lastIndexOf(id); + if (index !== -1) openSheetStack.splice(index, 1); + if (openSheetStack.length === 0) { + document.body.style.overflow = bodyScrollLockPreviousOverflow; + } +} + /** * Responsive overlay: a bottom sheet on mobile (rises from the bottom, safe-area * aware, drag-grip) and a centred dialog from `sm:` up. CSS-only animation, no @@ -87,6 +119,7 @@ export function Sheet({ const backdropPointerDownRef = useRef(false); const titleId = useId(); const descId = useId(); + const sheetId = useId(); useEffect(() => { onCloseRef.current = onClose; @@ -131,8 +164,7 @@ export function Sheet({ const explicitReturnElement = returnFocusRef?.current ?? null; const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; + pushSheet(sheetId); const focusFrame = window.requestAnimationFrame(() => { const focusTarget = initialFocusRef?.current ?? @@ -142,6 +174,12 @@ export function Sheet({ }); function onKeyDown(event: KeyboardEvent) { + // Only the top-most open Sheet reacts, so a stacked overlay (lightbox / + // table dialog over the Evidence sheet) does not also close on one Escape + // or fight over the Tab focus trap. Lower sheets registered their listener + // earlier and fire first, so they self-suppress here without needing to + // stop propagation. + if (!isTopmostSheet(sheetId)) return; if (event.key === "Escape") { event.preventDefault(); onCloseRef.current(); @@ -176,7 +214,7 @@ export function Sheet({ return () => { window.cancelAnimationFrame(focusFrame); window.removeEventListener("keydown", onKeyDown); - document.body.style.overflow = previousOverflow; + popSheet(sheetId); const restoreTarget = explicitReturnElement ?? previousActiveElement; window.requestAnimationFrame(() => { if (!restoreTarget?.isConnected) return; @@ -192,7 +230,7 @@ export function Sheet({ }, 50); }); }; - }, [open, initialFocusRef, returnFocusRef]); + }, [open, initialFocusRef, returnFocusRef, sheetId]); if (!open) return null; diff --git a/src/lib/scroll-behavior.ts b/src/lib/scroll-behavior.ts new file mode 100644 index 000000000..01d3a964c --- /dev/null +++ b/src/lib/scroll-behavior.ts @@ -0,0 +1,30 @@ +/** + * Reduced-motion-aware scroll behavior for scripted scrolls. + * + * `globals.css` sets `scroll-behavior: auto !important` under both + * `prefers-reduced-motion: reduce` and the app's own `html[data-motion="reduced"]` + * toggle, but per the CSSOM-View spec an explicit `behavior` in a + * `ScrollToOptions`/`ScrollIntoViewOptions` object overrides that CSS property. + * So any scripted `scrollTo`/`scrollIntoView` that hard-codes `behavior:"smooth"` + * animates regardless of the preference. Route those through + * {@link resolveScrollBehavior} instead so the "Reduce motion" control (and the OS + * setting) actually suppress the animation. + */ + +/** + * True when the user has asked for reduced motion, via either the OS media query + * or the in-app "Reduce motion" toggle (mirrored onto `` + * before first paint by `layout.tsx` and synced by `use-app-preferences.ts`). + * Safe on the server (returns `false`). + */ +export function prefersReducedMotion(): boolean { + if (typeof document !== "undefined" && document.documentElement.getAttribute("data-motion") === "reduced") { + return true; + } + return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; +} + +/** `"auto"` when reduced motion is preferred, otherwise `"smooth"`. */ +export function resolveScrollBehavior(): ScrollBehavior { + return prefersReducedMotion() ? "auto" : "smooth"; +} diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts index 79599d5af..85bb03624 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { RegistryRecordKind, RegistrySourceStatus, RegistryValidationStatus } from "@/lib/registry-records"; import type { ServiceRecord } from "@/lib/services"; @@ -18,6 +18,11 @@ export type RegistryRecordsState = { governance: Record; }; +/** Hook return: the list state plus a `refetch` that re-runs the request — e.g. + * from a Retry control after a transient error, since the fetch key is the kind + * (not a user value) and would otherwise only recover on a full page reload. */ +export type RegistryRecordsResult = RegistryRecordsState & { refetch: () => void }; + export type RegistryRecordGovernance = { sourceStatus: RegistrySourceStatus; validationStatus: RegistryValidationStatus; @@ -33,6 +38,9 @@ export type RegistryRecordState = { governance: RegistryRecordGovernance | null; }; +/** Hook return: the record state plus a `refetch` for Retry affordances. */ +export type RegistryRecordResult = RegistryRecordState & { refetch: () => void }; + const recordLoading: RegistryRecordState = { status: "loading", record: null, @@ -64,12 +72,20 @@ export function countVerifiedRegistryRecords(state: RegistryRecordsState) { export function useRegistryRecords( kind: RegistryRecordKind, options: { enabled?: boolean } = {}, -): RegistryRecordsState { +): RegistryRecordsResult { const enabled = options.enabled ?? true; const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); const [state, setState] = useState(recordsState("loading", kind)); + const [attempt, setAttempt] = useState(0); const visibleState: RegistryRecordsState = state.kind === kind ? state : recordsState("loading", kind); + // Re-run the request from a Retry control: reset to loading and bump a counter + // the effect depends on. Recovery otherwise required a full page reload. + const refetch = useCallback(() => { + setState(recordsState("loading", kind)); + setAttempt((value) => value + 1); + }, [kind]); + useEffect(() => { if (!enabled) return undefined; let active = true; @@ -119,16 +135,21 @@ export function useRegistryRecords( return () => { active = false; }; - }, [enabled, kind, authStatus, authorizationHeader, markSessionExpired]); + }, [enabled, kind, authStatus, authorizationHeader, markSessionExpired, attempt]); - return visibleState; + return { ...visibleState, refetch }; } /** Single owner-scoped registry record (detail pages). */ -export function useRegistryRecord(kind: RegistryRecordKind, slug: string): RegistryRecordState { +export function useRegistryRecord(kind: RegistryRecordKind, slug: string): RegistryRecordResult { const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); const requestKey = `${kind}:${slug}`; const [state, setState] = useState(recordLoading); + const [attempt, setAttempt] = useState(0); + const refetch = useCallback(() => { + setState(recordLoading); + setAttempt((value) => value + 1); + }, []); // Reset to loading during render when the target record changes, instead of // synchronously inside the effect (react-hooks/set-state-in-effect). const [lastRequestKey, setLastRequestKey] = useState(requestKey); @@ -184,7 +205,7 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis return () => { active = false; }; - }, [kind, slug, authStatus, authorizationHeader, markSessionExpired]); + }, [kind, slug, authStatus, authorizationHeader, markSessionExpired, attempt]); - return state; + return { ...state, refetch }; } diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index 66e965fd1..e0473ad07 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -142,7 +142,12 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(clinicalDashboardSource).toContain("resolveMobileComposerReserve("); expect(clinicalDashboardSource).toContain('from "@/components/clinical-dashboard/mobile-composer-reserve"'); expect(clinicalDashboardSource).not.toContain('bottomComposerHidden ? "max(0.75rem, env(safe-area-inset-bottom))"'); - expect(clinicalDashboardSource).toContain('"max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24"'); + expect(clinicalDashboardSource).toContain( + '"max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-24"', + ); + expect(clinicalDashboardSource).toContain( + '"max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-0"', + ); expect(documentViewerSource).toContain('data-testid="document-viewer-content"'); expect(documentViewerSource).toContain('"max-sm:pb-3"'); diff --git a/tests/favourites-demo-boundary.test.ts b/tests/favourites-demo-boundary.test.ts index 3f987b51f..7b07409c6 100644 --- a/tests/favourites-demo-boundary.test.ts +++ b/tests/favourites-demo-boundary.test.ts @@ -84,7 +84,7 @@ describe("favourites demo-data boundary", () => { expect(librarySource).toContain('"hidden min-w-0 max-w-full items-center gap-2.5 rounded-md text-left xl:flex"'); expect(librarySource).toContain('"block min-w-0 max-w-full rounded-md text-left xl:hidden"'); expect(librarySource).toContain( - '"xl:bg-[color:var(--clinical-accent-soft)]/45 xl:shadow-[inset_3px_0_0_var(--clinical-accent)]"', + '"xl:bg-[color:var(--clinical-accent-soft)]/45 xl:shadow-[var(--shadow-rail-active)]"', ); }); diff --git a/tests/icon-button.dom.test.tsx b/tests/icon-button.dom.test.tsx new file mode 100644 index 000000000..20dd767f7 --- /dev/null +++ b/tests/icon-button.dom.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { X } from "lucide-react"; +import { describe, expect, it, vi } from "vitest"; + +import { IconButton } from "@/components/ui-primitives"; + +describe("IconButton", () => { + it("uses the required label as the accessible name and hides the icon glyph", () => { + render(); + + const button = screen.getByRole("button", { name: "Dismiss notification" }); + expect(button).toHaveAttribute("type", "button"); + // The glyph is decorative: the accessible name must come only from the label, + // so the icon carries aria-hidden and contributes nothing to the name. + expect(button.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + }); + + it("fires onClick when enabled and never when disabled", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const { rerender } = render(); + + await user.click(screen.getByRole("button", { name: "Close" })); + expect(onClick).toHaveBeenCalledTimes(1); + + rerender(); + await user.click(screen.getByRole("button", { name: "Close" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("keeps the tap-target base while merging a chrome recipe from className", () => { + render(); + + const button = screen.getByRole("button", { name: "Zoom in" }); + expect(button.className).toContain("size-tap"); + expect(button.className).toContain("border"); + }); +}); diff --git a/tests/mode-home-status-notice.dom.test.tsx b/tests/mode-home-status-notice.dom.test.tsx new file mode 100644 index 000000000..269dfa11f --- /dev/null +++ b/tests/mode-home-status-notice.dom.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ShieldAlert } from "lucide-react"; +import { describe, expect, it, vi } from "vitest"; + +import { ModeHomeStatusNotice } from "@/components/mode-home-template"; + +describe("ModeHomeStatusNotice", () => { + it("renders a Retry button that calls onAction, with no navigation link", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(onAction).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it("renders a navigation link (not a button) when given actionHref", () => { + render( + , + ); + + expect(screen.getByRole("link", { name: "Open account setup" })).toHaveAttribute("href", "/"); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/tests/registry-retry.dom.test.tsx b/tests/registry-retry.dom.test.tsx new file mode 100644 index 000000000..b03add51e --- /dev/null +++ b/tests/registry-retry.dom.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { RegistryRecordLoader } from "@/components/registry-record-loader"; + +// vi.hoisted so the spy exists when the hoisted vi.mock factory runs. +const { refetch } = vi.hoisted(() => ({ refetch: vi.fn() })); + +vi.mock("@/lib/use-registry-records", () => ({ + useRegistryRecord: () => ({ + status: "error", + record: null, + linkedDocuments: [], + demoMode: false, + governance: null, + refetch, + }), +})); + +describe("RegistryRecordLoader error recovery", () => { + it("offers a Retry that calls refetch, keeping the home link as a secondary escape", async () => { + const user = userEvent.setup(); + render( + + {() =>
record body should not render in the error state
} +
, + ); + + expect(screen.getByText("Could not load the record")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(refetch).toHaveBeenCalledTimes(1); + + expect(screen.getByRole("link", { name: "Back to services" })).toBeInTheDocument(); + }); +}); diff --git a/tests/scroll-behavior.dom.test.tsx b/tests/scroll-behavior.dom.test.tsx new file mode 100644 index 000000000..e2e2f4824 --- /dev/null +++ b/tests/scroll-behavior.dom.test.tsx @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { prefersReducedMotion, resolveScrollBehavior } from "@/lib/scroll-behavior"; + +import { installMatchMediaStub } from "./setup/jsdom.setup"; + +describe("resolveScrollBehavior (jsdom)", () => { + afterEach(() => { + document.documentElement.removeAttribute("data-motion"); + installMatchMediaStub(false); + }); + + it("resolves to smooth when no reduced-motion signal is present", () => { + installMatchMediaStub(false); + expect(prefersReducedMotion()).toBe(false); + expect(resolveScrollBehavior()).toBe("smooth"); + }); + + it("resolves to auto when the OS prefers reduced motion", () => { + installMatchMediaStub(true); + expect(prefersReducedMotion()).toBe(true); + expect(resolveScrollBehavior()).toBe("auto"); + }); + + it("resolves to auto when the in-app Reduce motion toggle is set, even if the OS is not", () => { + installMatchMediaStub(false); + document.documentElement.setAttribute("data-motion", "reduced"); + expect(prefersReducedMotion()).toBe(true); + expect(resolveScrollBehavior()).toBe("auto"); + }); +}); diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx new file mode 100644 index 000000000..74e480653 --- /dev/null +++ b/tests/sheet.dom.test.tsx @@ -0,0 +1,97 @@ +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Sheet } from "@/components/ui/sheet"; + +// jsdom (via vitest's environment) normally provides requestAnimationFrame, but +// guard it so the Sheet's focus scheduling never throws if a runner omits it. +beforeEach(() => { + if (typeof window.requestAnimationFrame !== "function") { + window.requestAnimationFrame = ((cb: FrameRequestCallback) => + setTimeout(() => cb(Date.now()), 0) as unknown as number) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((id: number) => + clearTimeout(id as unknown as ReturnType)) as typeof window.cancelAnimationFrame; + } +}); + +afterEach(() => { + document.body.style.overflow = ""; +}); + +function Stacked({ + openA, + openB, + onCloseA, + onCloseB, +}: { + openA: boolean; + openB: boolean; + onCloseA: () => void; + onCloseB: () => void; +}) { + return ( + <> + +

Lower body

+
+ +

Upper body

+
+ + ); +} + +function pressEscape() { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); +} + +describe("Sheet stacked-overlay coordination", () => { + it("routes Escape to only the top-most open Sheet", () => { + const onCloseA = vi.fn(); + const onCloseB = vi.fn(); + render(); + + pressEscape(); + + expect(onCloseB).toHaveBeenCalledTimes(1); + expect(onCloseA).not.toHaveBeenCalled(); + }); + + it("keeps body scroll locked until the last Sheet closes, then restores the original overflow", () => { + const onCloseA = vi.fn(); + const onCloseB = vi.fn(); + const { rerender } = render(); + + // Both open → body scroll locked once. + expect(document.body.style.overflow).toBe("hidden"); + + // Close the upper (top) Sheet: the lower Sheet still holds the lock. + rerender(); + expect(document.body.style.overflow).toBe("hidden"); + + // After Escape now targets the lower Sheet (new top-most). + pressEscape(); + expect(onCloseA).toHaveBeenCalledTimes(1); + + // Close the last Sheet: original overflow ("") is restored exactly once. + rerender(); + expect(document.body.style.overflow).toBe(""); + }); + + it("locks body scroll for a single Sheet and restores it on close (unchanged baseline)", () => { + const onClose = vi.fn(); + const { rerender } = render( + +

Solo body

+
, + ); + expect(document.body.style.overflow).toBe("hidden"); + + rerender( + +

Solo body

+
, + ); + expect(document.body.style.overflow).toBe(""); + }); +});