diff --git a/docs/branch-review-records/18503906b6f41b6151d7298f2a14ba993d7b2f39f39414b70103aab3a0a8fcc0.record.md b/docs/branch-review-records/18503906b6f41b6151d7298f2a14ba993d7b2f39f39414b70103aab3a0a8fcc0.record.md new file mode 100644 index 000000000..b17472570 --- /dev/null +++ b/docs/branch-review-records/18503906b6f41b6151d7298f2a14ba993d7b2f39f39414b70103aab3a0a8fcc0.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 1e3d9278fbddda7d36d12e5645429b33d2a374ce | Calculators first-class mode | Resolved the current-base documentation conflict; prior P2 local-search and options-menu corrections remain present; no further confirmed P0-P2 findings. | git diff --check; ledger inbox/outstanding-issues/branch-ledger/discipline guards; calculator static contracts; focused Vitest attempted but unavailable because the isolated worktree has no node_modules | diff --git a/docs/branch-review-records/1bcd7481c03879170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md b/docs/branch-review-records/1bcd7481c03879170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md new file mode 100644 index 000000000..ce510798d --- /dev/null +++ b/docs/branch-review-records/1bcd7481c03879170cc5f4dc265e6b1e647dac6fb84d348f6b12fea7f87e7968.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 1938a6bc5f1845c67c85584ded78b3be1c8a961a | Calculators first-class mode | Merged the latest required base after the final freshness check; calculator local-search and action-menu P2 corrections remain intact. | git diff --check; ledger inbox/outstanding-issues/branch-ledger/discipline guards; reconciled search-chrome contract assertion; focused Vitest remains unavailable because this isolated worktree has no node_modules | diff --git a/docs/branch-review-records/1f2f64dc3b528b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md b/docs/branch-review-records/1f2f64dc3b528b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md new file mode 100644 index 000000000..3022e100c --- /dev/null +++ b/docs/branch-review-records/1f2f64dc3b528b7149b664c65dc98e4b7e10477e73f22ca8c1ccfe02d4b84f50.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | bbf2207102a0b40b5ba048e8e9a04f37add9bf2c | required base sync through main 3824095 | Approved — required main update merged after calculator command follow-ups; no PR-path conflict | git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed | diff --git a/docs/branch-review-records/24336297cd7a192b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md b/docs/branch-review-records/24336297cd7a192b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md new file mode 100644 index 000000000..bd396c8cb --- /dev/null +++ b/docs/branch-review-records/24336297cd7a192b04d348313e763ff4a51012c6de6ec22028f0f2b600adeaa7.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 3b896be3f5f6ea67df10c991257aeb05b41af619 | Fix calculator regression tests from exact-head CI and merge main d301d8f4 | fixed | git diff --check; static ledger guards; focused tests blocked without node_modules | diff --git a/docs/branch-review-records/35ae4a75db629a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md b/docs/branch-review-records/35ae4a75db629a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md new file mode 100644 index 000000000..0a36ee596 --- /dev/null +++ b/docs/branch-review-records/35ae4a75db629a7a9ee8b0daf3b933bb51b159fedb9b0e5fce759cb470f06252.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 1ec9eb1028bf29ac633857846fe69c91249a64c7 | calculator suggestion regression test accessibility and formatting follow-up | Fixed the focused calculator suggestion test to match the full accessible option label; corrected formatter-required nested ternary indentation. Targeted Vitest unavailable because this isolated worktree has no node_modules/vitest. | git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; ci-change-scope --self-test | diff --git a/docs/branch-review-records/3c872a41535a5852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md b/docs/branch-review-records/3c872a41535a5852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md new file mode 100644 index 000000000..5d0c4169b --- /dev/null +++ b/docs/branch-review-records/3c872a41535a5852133f97afa60a07d554e02ec2055fd40acb890720fbcdd8c3.record.md @@ -0,0 +1 @@ +| 2026-08-14 | codex/calculators-mode | 8e7c9d65463ab37bc6e6cd658ec8091b4260db18 | calculators first-class mode | Clean after resolving local-only typeahead and legacy URL normalization | 20/20 focused unit/DOM; typecheck; lint; prior verify:ui 433/433; targeted browser request-interception queued then not run due coordinator contention | diff --git a/docs/branch-review-records/4dbb7f7d041801dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md b/docs/branch-review-records/4dbb7f7d041801dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md new file mode 100644 index 000000000..4fa0ca268 --- /dev/null +++ b/docs/branch-review-records/4dbb7f7d041801dbdbfb1360d45e46027a5b3debdb029af744345900ca5acdc1.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | d8fde21e65ce010b72bc56f1e57334cdfac764c5 | required base sync through main 17402395 | Approved — required main update merged; prior calculator-mode review remains applicable with no PR-path conflict | git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed | diff --git a/docs/branch-review-records/5c0ac2f4e15f46fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md b/docs/branch-review-records/5c0ac2f4e15f46fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md new file mode 100644 index 000000000..19f50bb0e --- /dev/null +++ b/docs/branch-review-records/5c0ac2f4e15f46fb39e8fd09c38c3cc169be8ad8f8459b70b86140a155a88967.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | ff3dfa4e32e63ccf288438a6371608b42847c18e | calculator search-chrome documentation formatter follow-up | Aligned the search-chrome ownership table to the new longest mode label after the changed-file formatter reported Markdown table drift. | git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; ci-change-scope --self-test | diff --git a/docs/branch-review-records/711ddbc0e2119fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md b/docs/branch-review-records/711ddbc0e2119fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md new file mode 100644 index 000000000..8dc6e9d4f --- /dev/null +++ b/docs/branch-review-records/711ddbc0e2119fb166c1c4267644135f54ba5f04f0c8c06a38fc88ddc42c85e6.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 39423d88bced6494ad2eed30f43fb859ab6abefb | Calculators mode: final current-base merge | Merged latest required base after prior focused review; no conflicts or new confirmed P0-P2 findings | git diff --check; ledger-inbox; outstanding-issues; branch-review-ledger; ledger-write-discipline; calculator registration assertion | diff --git a/docs/branch-review-records/7b17944b9be99b8dfecf5a608e68fcee0660337191337c4097bc1831ccd34960.record.md b/docs/branch-review-records/7b17944b9be99b8dfecf5a608e68fcee0660337191337c4097bc1831ccd34960.record.md new file mode 100644 index 000000000..05625cdf9 --- /dev/null +++ b/docs/branch-review-records/7b17944b9be99b8dfecf5a608e68fcee0660337191337c4097bc1831ccd34960.record.md @@ -0,0 +1 @@ +| 2026-08-14 | codex/calculators-mode | 563ce4195512b9e623df6ba98762f4a3dc1b9e8e | calculators first-class mode | P1: calculator composer invokes universal-search API despite local-only boundary | source diff review; local verification evidence inspected; verify:pr-local dry-run; provider checks not run | diff --git a/docs/branch-review-records/b22a65eca447a754f2a971dae18a24a12fc5f3b7bb18a89e813bc2d9a11d1e12.record.md b/docs/branch-review-records/b22a65eca447a754f2a971dae18a24a12fc5f3b7bb18a89e813bc2d9a11d1e12.record.md new file mode 100644 index 000000000..da41f47c8 --- /dev/null +++ b/docs/branch-review-records/b22a65eca447a754f2a971dae18a24a12fc5f3b7bb18a89e813bc2d9a11d1e12.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/calculators-mode | 9dc3891ee8bc30a35fdb203607cdd984d3a24cc3 | calculator command-surface P1/P2 follow-up: suggestion submit and footer ownership docs | Fixed P1/P2 — selected calculator suggestions pass their exact text into navigation; chrome ownership docs no longer describe calculators as page-owned footers | manual control-flow review; focused DOM regression added; git diff --check; ci-change-scope self-test; ledger/inbox/outstanding/discipline guards passed; focused Vitest blocked: node_modules/vitest absent | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index f10fa1ab2..df06a54db 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -4,17 +4,17 @@ This repo uses one shared search experience across the global shell, dashboard r ## Page ownership model -| Page state | Composer placement | Reserve owner | -| ------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Answer home / conventional standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | -| Tools directory (`/tools`, no submitted query) | In-flow composer above the all-tools results directory | Page content; no fixed phone dock reserve | -| Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | -| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | -| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | -| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | -| Record page breadcrumb header | Same header row without the disclosure or track; view mode inline from `sm` | None — portals into the phone collapse row, sticky at `sm+` | -| Calculators (`/calculators`) | Page-owned composer (desktop top + phone bottom dock) | Calculators page pad; shell reserve stays `0` | -| Info/detail pages with no composer | No fixed composer | Idle shell padding only | +| Page state | Composer placement | Reserve owner | +| ---------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | +| Tools directory (`/tools`, no submitted query) | In-flow composer above the all-tools results directory | Page content; no fixed phone dock reserve | +| Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | +| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | +| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | +| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | +| Record page breadcrumb header | Same header row without the disclosure or track; view mode inline from `sm` | None — portals into the phone collapse row, sticky at `sm+` | +| Calculators (`/calculators`) | In-flow hero composer at home; shared compact dock after submission | Page content at home; shell reserve for submitted results | +| Info/detail pages with no composer | No fixed composer | Idle shell padding only | ## Default in-page navigation template @@ -321,7 +321,7 @@ in-page navigation work defaults to the DocumentViewer template above. 6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide. **Collapse motion (tablet and desktop only):** when the top bar is hidden, `chrome-safe-area-top` and the controls both release to `0rem` so underlying content paints to the physical viewport edge. **Overlay motion (every phone route on both hosts, no exception):** the stack translates instead; `chrome-safe-area-top` stays inside the translated layer at a stable height, and the content-facing `--phone-overlay-chrome-h` clearance is constant across hide/reveal — zeroing it on hide would reintroduce the layout shift overlay exists to remove. The visible phone header still owns `var(--safe-area-top)`; tablet/desktop top-bar chrome keeps its pinned inset. While visible that spacer is the top of the header, so it paints `var(--surface)` — the bar's own opaque phone colour — never `var(--background)`: the page colour there reads as a status-bar band above the bar, the seam overlay-strategy answer mode never shows because its header pads the inset itself. Keep it opaque so the sm+ pinned inset still hides scrolled content. Top-bar hide/reveal is cross-breakpoint; the search field belongs to page flow and scrolls away naturally on tablets and desktops; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). Read "Scroll hide/reveal" below before changing either. 7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. -9. Page-owned fixed phone composers follow the same release contract: calculators use the shared footer backdrop; DocumentViewer keeps its floating pill but synchronizes transform, opacity, pointer release, and its own zero-reserve content padding. In-flow hero composers remain free of fixed-footer glass. +9. Page-owned fixed phone composers follow the same release contract: DocumentViewer keeps its floating pill but synchronizes transform, opacity, pointer release, and its own zero-reserve content padding. In-flow hero composers remain free of fixed-footer glass; Calculators uses the shared shell dock after submission. 10. Keep collapse-budget policy geometry-aware: an in-flow collapsing phone header needs enough remaining runway to absorb controls + released top safe-area + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. 11. Detect reserve-transition clamps from geometry, not a wider pixel tolerance: if the scroll range shrinks and the previous offset no longer fits inside the new maximum, rebase that frame as layout feedback. Once the range stabilizes, the same upward movement must reveal normally. 12. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize. @@ -553,7 +553,7 @@ raw CSS `transform: translateY(...)` in `globals.css` are unaffected and stay on `MasterSearchHeader` additionally wraps the composer in `PhoneFooterLayerPortal` when `phoneOverlayMotion && usesPhoneBottomDock`, which covers the during-hide window when the transform genuinely exists, and matches the mechanism invariant -21 already requires of the DocumentViewer, calculator and differential footers. +21 already requires of the DocumentViewer and differential footers. Treat it as defence in depth, not the primary fix — `PhoneFooterLayerPortal` starts with `isPhone === false`, so it does nothing until hydration and cannot protect first paint on its own. Do not solve any of this by rendering the @@ -565,7 +565,7 @@ Rules that keep this working: - **Hide the top bar, not the search field.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus page navigation mounted through `PhoneHeaderCollapsePortal` into `#phone-header-collapse-addon-slot`. Keep composers outside the collapse row: tablet and desktop result search scroll with page content rather than being translated by the header. - **Every production phone navigation header has one collapse owner.** `PhoneHeaderCollapsePortal` moves Therapy section navigation, DocumentViewer navigation, and Differential detail navigation into `#phone-header-collapse-addon-slot` below `sm`; the same subtree stays in its existing page position at `sm+`. Do not add a second sticky/fixed phone header inside `#main-content`: the universal collapse row must own its safe area, focus pinning, timing, clipping, and measured release. Semantic content headings and modal/sheet headers are not viewport chrome and stay in their own flow/scroll context. - **Document phone headers overlay as one stable stack.** Document detail/source routes keep the complete phone header at a stable height and translate the safe area plus both header rows and the section track together. Hidden overlay chrome is transparent and non-interactive; revealed chrome frosts and covers the document. `readChromeCollapseMetrics` counts zero released top-header geometry for this overlay, while continuing to measure the independently hidden document composer reserve. Reveal must not change the active owner's scroll offset or a stable document/PDF anchor. -- **Feed the reporter from the element that actually scrolls.** Both app hosts run `useDocumentScrollHideReporter` alongside their `
` reporter. In browser-mode phones the normal-flow shell and `overflow-y: visible` surface make the document the only vertical owner, which lets Safari minimize its browser UI. Installed standalone mode uses a normal-flow `100vh` shell plus bounded inner surface, so document scroll does not fire there. The hook measures the same collapse budget and blurs the same focused composer for either owner. Page-owned footer chrome must follow the same rule: `DocumentViewer` observes both the document and the inner surface, then combines the signals so only the active owner drives it. Its rendered footer, like calculator and differential page-owned footers, portals to the frame host so observing the inner scroll owner does not make the footer its descendant. +- **Feed the reporter from the element that actually scrolls.** Both app hosts run `useDocumentScrollHideReporter` alongside their `
` reporter. In browser-mode phones the normal-flow shell and `overflow-y: visible` surface make the document the only vertical owner, which lets Safari minimize its browser UI. Installed standalone mode uses a normal-flow `100vh` shell plus bounded inner surface, so document scroll does not fire there. The hook measures the same collapse budget and blurs the same focused composer for either owner. Page-owned footer chrome must follow the same rule: `DocumentViewer` observes both the document and the inner surface, then combines the signals so only the active owner drives it. Its rendered footer, like the differential page-owned footer, portals to the frame host so observing the inner scroll owner does not make the footer its descendant. - **Keep browser-phone chrome attached to the viewport without fixing the app root.** Collapse-mode headers use one phone-sticky wrapper; answer overlay headers are fixed only in browser mode and remain absolute over the inner surface in standalone mode. Footer layers are likewise viewport-fixed in browser mode and shell-absolute in standalone. While either header or reserve transition changes document geometry, the corresponding transition marker disables anchoring on the active document/inner scroller so synthetic reverse scroll cannot cause a hide/reveal loop or reading-position jump. - **Do not treat CSSOM bounds as physical iOS paint proof.** A fixed root can report perfect `getBoundingClientRect()` and hit-testing while WebKit leaves an app-external band. Keep browser/document and standalone/`100vh` static guards, then verify Safari and a freshly relaunched Home Screen app on a physical phone before merge. If iOS reports a web viewport shorter than `screen.height`, pixels outside that viewport are system-owned; keep the root canvas opaque and matching, but do not fake reachability with negative safe-area overscan. - **Viewport stickiness belongs on the outer top-bar stack, not on `header#search`.** The top bar sits inside header-height boxes, which leaves a sticky rule on it zero travel. For the same reason the visible stack's ancestor in `GlobalSearchShell` is `display: contents` at every breakpoint rather than a block. The collapse result owns one phone-sticky wrapper (safe-area spacer + stack), while its `sm:` children retain the tablet/desktop offsets. At tablet and desktop widths the search portal leaves that outer stack holding only the top bar. @@ -577,7 +577,7 @@ Rules that keep this working: - **Rebase the reporter on geometry switches.** Pass `resetKey` when the host changes the scrollport under it (`ClinicalDashboard` passes `searchMode`, which swaps `
`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal. Shared mode-home shells should also reset on `pathname` so collapsed chrome/scroll offset does not carry across modes. - **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and both hosts must blur the dock input when the active result owner scrolls so hide-on-scroll can reclaim the header and bottom dock. -Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet/desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (shared shell header hide/reveal, per-mode top-edge release, collapse owner), `tests/ui-phone-scroll-routes.spec.ts` (per-route phone scroll sweep), `tests/ui-phone-scroll-page-owned.spec.ts` (document-viewer composer, standalone frame-owned footers, calculators dock, Services canvas) — the three share `tests/helpers/phone-scroll.ts`, and `tests/playwright-project-isolation.test.ts` asserts every sibling is collected by the required browser projects, `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). +Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet/desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (shared shell header hide/reveal, per-mode top-edge release, collapse owner), `tests/ui-phone-scroll-routes.spec.ts` (per-route phone scroll sweep, including submitted calculator results), `tests/ui-phone-scroll-page-owned.spec.ts` (document-viewer composer, standalone frame-owned footers, Services canvas) — the three share `tests/helpers/phone-scroll.ts`, and `tests/playwright-project-isolation.test.ts` asserts every sibling is collected by the required browser projects, `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). Run `npm run verify:phone-chrome` for phone-chrome work. For executable changes its classifier checks installed/lock parity first, runs focused static contracts and only the browser/PWA owners and route journeys implicated by the changed files, then escalates to `npm run verify:ui` automatically for shared chrome foundations. Documentation-only scopes run only documentation guards. Use `-- --dry-run` to inspect the plan, `-- --files ` for an explicit scope, and `-- --full=always|never` only for a deliberate override. diff --git a/docs/site-map.md b/docs/site-map.md index 0aaef06d0..d76c55863 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -59,6 +59,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. - `/medications` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/tools` - Tools mode. Search kind: `tools`. Query example: `/tools?q=medications&focus=1&run=1`. +- `/calculators` - Calculators mode. Search kind: `calculators`. Query example: `/calculators?q=PHQ-9&focus=1&run=1`. - `/therapy-compass` - Therapy mode. Search kind: `tools`. Query example: `/therapy-compass?q=behavioural+activation&focus=1&run=1`. - `/factsheets` - Factsheets mode. Search kind: `tools`. Query example: `/factsheets/search?q=sertraline&focus=1&run=1`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 243278320..dd263175c 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -277,6 +277,7 @@ function renderModeRoutes() { formulation: appModeHomeHref("formulation", { query: "I keep going over it", focus: true, run: true }), prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + calculators: appModeHomeHref("calculators", { query: "PHQ-9", focus: true, run: true }), "therapy-compass": appModeHomeHref("therapy-compass", { query: "behavioural activation", focus: true, run: true }), factsheets: appModeHomeHref("factsheets", { query: "sertraline", focus: true, run: true }), }; diff --git a/src/app/(search-app)/calculators/page.tsx b/src/app/(search-app)/calculators/page.tsx index cb6ca9895..802403f7a 100644 --- a/src/app/(search-app)/calculators/page.tsx +++ b/src/app/(search-app)/calculators/page.tsx @@ -1,12 +1,45 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -import { CalculatorsSearchPage } from "@/components/calculators"; +import { CalculatorsHomePage, CalculatorsSearchPage } from "@/components/calculators"; export const metadata: Metadata = { title: "Calculators - Clinical KB", description: "Psychiatry clinical decision calculators and rating scales with source-cited scoring guidance.", }; -export default function CalculatorsRoute() { - return ; +type CalculatorsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +function toURLSearchParams(params: Awaited) { + const normalized = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) value.forEach((item) => normalized.append(key, item)); + else if (value !== undefined) normalized.set(key, value); + } + return normalized; +} + +export default async function CalculatorsRoute({ searchParams }: { searchParams: CalculatorsSearchParams }) { + const resolvedSearchParams = await searchParams; + const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1"; + const primaryQuery = readFirstSearchParam(resolvedSearchParams.q)?.trim(); + const legacyQuery = readFirstSearchParam(resolvedSearchParams.query)?.trim(); + const query = primaryQuery || legacyQuery; + + if (resolvedSearchParams.query !== undefined) { + const canonicalSearchParams = toURLSearchParams(resolvedSearchParams); + if (query) canonicalSearchParams.set("q", query); + else canonicalSearchParams.delete("q"); + canonicalSearchParams.delete("query"); + const suffix = canonicalSearchParams.toString(); + redirect(suffix ? `/calculators?${suffix}` : "/calculators"); + } + + if (!hasSubmittedSearch || !query) return ; + + return ; } diff --git a/src/components/calculators/calculator-filters.ts b/src/components/calculators/calculator-filters.ts new file mode 100644 index 000000000..efdd4f77e --- /dev/null +++ b/src/components/calculators/calculator-filters.ts @@ -0,0 +1,94 @@ +import { domainLabels, type CalculatorDomain, type CalculatorFixture } from "./calculator-fixtures"; +import type { DerivedCalculator } from "./calculator-ui"; + +export type CalculatorProgressFilter = "all" | "not-started" | "in-progress" | "completed"; +export type CalculatorTimeFilter = "all" | "quick" | "standard" | "extended"; + +export type CalculatorFilterState = { + domains: ReadonlySet; + progress: CalculatorProgressFilter; + time: CalculatorTimeFilter; +}; + +export type CalculatorFilterRecord = { + calc: CalculatorFixture; + derived: DerivedCalculator; +}; + +export function normalizeCalculatorQuery(query: string) { + return query.trim().toLowerCase(); +} + +export function calculatorMatchesQuery(calc: CalculatorFixture, query: string) { + const normalized = normalizeCalculatorQuery(query); + if (!normalized) return true; + const haystack = [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]] + .join(" ") + .toLowerCase(); + return haystack.includes(normalized) || calc.items.some((item) => item.text.toLowerCase().includes(normalized)); +} + +export function calculatorMatchesProgress(derived: DerivedCalculator, progress: CalculatorProgressFilter) { + if (progress === "all") return true; + if (progress === "not-started") return !derived.started; + if (progress === "completed") return derived.complete; + return derived.started && !derived.complete; +} + +export function calculatorMatchesTime(calc: CalculatorFixture, time: CalculatorTimeFilter) { + if (time === "all") return true; + if (time === "quick") return calc.timeEstimateMinutes.max <= 2; + if (time === "standard") return calc.timeEstimateMinutes.max >= 3 && calc.timeEstimateMinutes.max <= 4; + return calc.timeEstimateMinutes.max >= 5; +} + +export function calculatorMatchesFilters( + record: CalculatorFilterRecord, + query: string, + filters: CalculatorFilterState, +) { + return ( + calculatorMatchesQuery(record.calc, query) && + (filters.domains.size === 0 || filters.domains.has(record.calc.domain)) && + calculatorMatchesProgress(record.derived, filters.progress) && + calculatorMatchesTime(record.calc, filters.time) + ); +} + +export function filterCalculatorRecords( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, +) { + return records.filter((record) => calculatorMatchesFilters(record, query, filters)); +} + +export function calculatorDomainCandidateCount( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, + candidate: CalculatorDomain, +) { + const domains = new Set(filters.domains); + if (domains.has(candidate)) domains.delete(candidate); + else domains.add(candidate); + return filterCalculatorRecords(records, query, { ...filters, domains }).length; +} + +export function calculatorProgressCandidateCount( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, + candidate: CalculatorProgressFilter, +) { + return filterCalculatorRecords(records, query, { ...filters, progress: candidate }).length; +} + +export function calculatorTimeCandidateCount( + records: readonly CalculatorFilterRecord[], + query: string, + filters: CalculatorFilterState, + candidate: CalculatorTimeFilter, +) { + return filterCalculatorRecords(records, query, { ...filters, time: candidate }).length; +} diff --git a/src/components/calculators/calculator-fixtures.ts b/src/components/calculators/calculator-fixtures.ts index c940c7415..c1eb34f7f 100644 --- a/src/components/calculators/calculator-fixtures.ts +++ b/src/components/calculators/calculator-fixtures.ts @@ -61,6 +61,8 @@ export type CalculatorFixture = { /** Question stem shown above the items. */ stem?: string; timeEstimate: string; + /** Numeric range used by the local completion-time filter. */ + timeEstimateMinutes: { min: number; max: number }; minScore: number; maxScore: number; items: CalculatorItem[]; @@ -121,6 +123,7 @@ export const calculators: CalculatorFixture[] = [ summary: "9-item depression severity score with treatment-action bands.", stem: "Over the last 2 weeks, how often have you been bothered by:", timeEstimate: "2–3 min", + timeEstimateMinutes: { min: 2, max: 3 }, minScore: 0, maxScore: 27, scoringNote: "Sum of 9 items (0–3 each). Severity bands map to stepped treatment actions.", @@ -199,6 +202,7 @@ export const calculators: CalculatorFixture[] = [ summary: "7-item anxiety severity score; also performs well for panic and social anxiety.", stem: "Over the last 2 weeks, how often have you been bothered by:", timeEstimate: "1–2 min", + timeEstimateMinutes: { min: 1, max: 2 }, minScore: 0, maxScore: 21, scoringNote: "Sum of 7 items (0–3 each). ≥10 is the usual cut-point for probable GAD.", @@ -242,6 +246,7 @@ export const calculators: CalculatorFixture[] = [ summary: "10-item distress measure scored 10–50, widely used across Australian primary care.", stem: "In the past 4 weeks, about how often did you feel:", timeEstimate: "2–3 min", + timeEstimateMinutes: { min: 2, max: 3 }, minScore: 10, maxScore: 50, scoringNote: "Sum of 10 items (1–5 each); range 10–50. Higher scores indicate greater distress.", @@ -293,6 +298,7 @@ export const calculators: CalculatorFixture[] = [ summary: "13 lifetime hypomanic symptoms plus co-occurrence and impairment criteria.", stem: "Has there ever been a period of time when you were not your usual self and you…", timeEstimate: "3–4 min", + timeEstimateMinutes: { min: 3, max: 4 }, minScore: 0, maxScore: 13, scoringNote: @@ -399,6 +405,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Rapid 4-question lifetime screen for problem drinking in adults.", summary: "Four yes/no questions; two or more positives is a clinically significant screen.", timeEstimate: "under 1 min", + timeEstimateMinutes: { min: 0, max: 1 }, minScore: 0, maxScore: 4, scoringNote: "1 point per “yes”. ≥2 is a positive screen; follow with AUDIT and drinking history.", @@ -440,6 +447,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Brief consumption screen for hazardous drinking — the first three AUDIT items.", summary: "3 consumption questions scored 0–4 each; sex-specific positive thresholds.", timeEstimate: "1 min", + timeEstimateMinutes: { min: 1, max: 1 }, minScore: 0, maxScore: 12, scoringNote: "Positive screen at ≥3 for women and ≥4 for men. Higher totals track hazard severity.", @@ -509,6 +517,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Structure a rapid inventory of static suicide-risk factors as an adjunct to clinical assessment.", summary: "10 yes/no risk factors, one point each; supports — never replaces — clinical judgement.", timeEstimate: "1–2 min", + timeEstimateMinutes: { min: 1, max: 2 }, minScore: 0, maxScore: 10, scoringNote: "1 point per factor present. Bands are indicative only; act on clinical concern at any score.", @@ -566,6 +575,7 @@ export const calculators: CalculatorFixture[] = [ indication: "Grade the severity of obsessive-compulsive symptoms and track treatment response.", summary: "10 severity items (0–4) across obsessions and compulsions; total 0–40.", timeEstimate: "5–8 min", + timeEstimateMinutes: { min: 5, max: 8 }, minScore: 0, maxScore: 40, scoringNote: "Sum of 10 items. Items 1–5 grade obsessions, 6–10 compulsions; subscales can be reported separately.", diff --git a/src/components/calculators/home-page.tsx b/src/components/calculators/home-page.tsx new file mode 100644 index 000000000..3c06703e8 --- /dev/null +++ b/src/components/calculators/home-page.tsx @@ -0,0 +1,58 @@ +import { Calculator, CloudRain, GlassWater, Search, Zap } from "lucide-react"; + +import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { appModeHomeHref } from "@/lib/app-modes"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; + +const calculatorQuickLinks = ["PHQ-9", "GAD-7", "K10", "MDQ", "AUDIT-C"] as const; + +function calculatorSearchHref(query: string) { + return appModeHomeHref("calculators", { query, run: true }); +} + +export function CalculatorsHomePage() { + return ( + + ({ + label, + href: calculatorSearchHref(label), + icon: Search, + }))} + footer={ + + } + /> + + ); +} diff --git a/src/components/calculators/index.ts b/src/components/calculators/index.ts index 759635cac..c9aeaf4e5 100644 --- a/src/components/calculators/index.ts +++ b/src/components/calculators/index.ts @@ -2,6 +2,7 @@ export { CalculatorsBedsideSheetMockup } from "./bedside-sheet"; export { CalculatorsClinicalConsoleMockup } from "./clinical-console"; export { CalculatorsDirectoryGridMockup } from "./directory-grid"; export { CalculatorsGuidedFlowMockup } from "./guided-flow"; +export { CalculatorsHomePage } from "./home-page"; export { CalculatorsPopupSheetMockup } from "./calculator-sheet"; export { CalculatorsSearchPage } from "./search-page"; export { CalculatorsSearchDetailMockup } from "./search-detail"; diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index c6bb6f4ea..6599ba091 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -1,49 +1,44 @@ "use client"; -import { - ArrowRight, - Calculator, - Clock3, - History, - Info, - LayoutGrid, - ListChecks, - Plus, - Rows3, - Search, - Send, - Sigma, - SlidersHorizontal, - X, -} from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRight, Clock3, History, Info, LayoutGrid, ListChecks, Rows3, Search, Sigma } from "lucide-react"; +import { useEffect, useId, useMemo, useState, useSyncExternalStore } from "react"; -import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { - PhoneFooterLayerPortal, - usePhoneFooterLayerScrollHidden, -} from "@/components/clinical-dashboard/phone-footer-layer-portal"; + ResultFilterSheet, + ResultFilterTrigger, + resultFilterFacetGroup, + resultFilterGroup, +} from "@/components/clinical-dashboard/result-filter-control"; +import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; -import { useHideOnScroll, useReserveTransitionMarker } from "@/components/clinical-dashboard/use-hide-on-scroll"; -import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import { - chatComposerInput, - chatComposerShellBase, - chatSendButton, - cn, - eyebrowText, - ignoreUnavailableActivation, -} from "@/components/ui-primitives"; + SearchResultsEmptyState, + SearchResultsHeaderBand, + type AppliedFilterChip, +} from "@/components/clinical-dashboard/search-results-header-band"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { appModeHomeHref } from "@/lib/app-modes"; +import { + calculatorDomainCandidateCount, + calculatorProgressCandidateCount, + calculatorTimeCandidateCount, + filterCalculatorRecords, + normalizeCalculatorQuery, + type CalculatorFilterState, + type CalculatorProgressFilter, + type CalculatorTimeFilter, +} from "./calculator-filters"; import { calculators, - domainIcons, domainLabels, domainOrder, plannedCalculators, type CalculatorDomain, type CalculatorFixture, } from "./calculator-fixtures"; +import { CalculatorSheet } from "./calculator-sheet"; import { MetaPill, SeverityPill, @@ -54,12 +49,30 @@ import { type AnswerMap, type DerivedCalculator, } from "./calculator-ui"; -import { CalculatorSheet } from "./calculator-sheet"; -type DomainFilter = CalculatorDomain | "all"; type SessionAnswers = Record; type Density = "comfortable" | "compact"; +const subscribeNoop = () => () => undefined; + +const progressOptions: ReadonlyArray<{ value: CalculatorProgressFilter; label: string }> = [ + { value: "all", label: "Any" }, + { value: "not-started", label: "Not started" }, + { value: "in-progress", label: "In progress" }, + { value: "completed", label: "Completed" }, +]; + +const timeOptions: ReadonlyArray<{ value: CalculatorTimeFilter; label: string }> = [ + { value: "all", label: "Any" }, + { value: "quick", label: "2 minutes or less" }, + { value: "standard", label: "3–4 minutes" }, + { value: "extended", label: "5+ minutes" }, +]; + +function optionLabel(options: ReadonlyArray<{ value: Value; label: string }>, value: Value) { + return options.find((option) => option.value === value)?.label ?? value; +} + /** Match context: name / indication hit, or the first matching item text. */ function matchContext(calc: CalculatorFixture, query: string): string | null { if (!query) return null; @@ -67,126 +80,6 @@ function matchContext(calc: CalculatorFixture, query: string): string | null { return item ? item.text : null; } -function matches(calc: CalculatorFixture, query: string): boolean { - if (!query) return true; - const haystack = [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]] - .join(" ") - .toLowerCase(); - return haystack.includes(query) || calc.items.some((item) => item.text.toLowerCase().includes(query)); -} - -/* ---------- universal-style search composer (top on desktop, docked bottom on phones) ---------- */ - -// Example searches shown in the composer prompt row; each filters the list. -const promptExamples = ["depression", "anxiety", "drinking", "bipolar", "suicide"]; - -/** - * The calculators search composer, matching the app's universal composer: a - * leading "+" (new search), the query input with an inline clear, and the teal - * send button. `variant="full"` adds the Smart-search hint, prompt chips, and - * privacy notice (desktop header); `variant="compact"` shows the pill plus the - * privacy line only (phone bottom dock). - */ -function CalculatorComposer({ - query, - onQuery, - onReset, - onSubmit, - variant, -}: { - query: string; - onQuery: (value: string) => void; - onReset: () => void; - onSubmit: () => void; - variant: "full" | "compact"; -}) { - return ( -
- {variant === "full" ? ( -
- Smart search - - - Try “depression severity” in Calculators. - -
- ) : null} - -
{ - event.preventDefault(); - onSubmit(); - }} - className={cn(chatComposerShellBase, "answer-footer-search-pill relative z-10 w-full")} - > - - - - -
- ); -} - -/* ---------- home-page-style calculator tile ---------- */ - function CalculatorTile({ calc, derived, @@ -262,144 +155,40 @@ function CalculatorTile({ ); } -/* ---------- results header band (count + eyebrow + controls) ---------- */ - -function ResultsHeaderBand({ - count, - query, - density, - onDensity, -}: { - count: number; - query: string; - density: Density; - onDensity: (next: Density) => void; -}) { +function DensityControl({ density, onDensity }: { density: Density; onDensity: (next: Density) => void }) { return ( -
-
-
- - {count} - -
-

- Clinical calculators -

-

- {count} {count === 1 ? "calculator" : "calculators"} -

-

- {query ? ( - <> - Matching “{query}”. Open one to score - it and see next actions. - - ) : ( - "Validated psychiatry scores. Open one to score it and see score-linked next actions." - )} -

-
-
-
- {( - [ - ["comfortable", LayoutGrid, "Comfortable"], - ["compact", Rows3, "Compact"], - ] as const - ).map(([value, DensityIcon, label]) => { - const active = density === value; - return ( - - ); - })} -
-
+
+ {( + [ + ["comfortable", LayoutGrid, "Comfortable"], + ["compact", Rows3, "Compact"], + ] as const + ).map(([value, Icon, label]) => ( + + ))}
); } -/* ---------- right rail ---------- */ - -function DomainNav({ - domain, - counts, - onSelect, -}: { - domain: DomainFilter; - counts: Record; - onSelect: (next: DomainFilter) => void; -}) { - const rows: { id: DomainFilter; label: string; icon: typeof Calculator }[] = [ - { id: "all", label: "All calculators", icon: Calculator }, - ...domainOrder.map((entry) => ({ - id: entry as DomainFilter, - label: domainLabels[entry], - icon: domainIcons[entry], - })), - ]; - - return ( -
-

Browse by domain

-
- {rows.map((row) => { - const active = domain === row.id; - const RowIcon = row.icon; - const count = row.id === "all" ? calculators.length : (counts[row.id] ?? 0); - return ( - - ); - })} -
-
- ); -} - function ContinuePanel({ inProgress, onOpen, @@ -463,45 +252,48 @@ function AboutPanel() { ); } -/* ---------- page ---------- */ - -const filterChips: { id: DomainFilter; label: string }[] = [ - { id: "all", label: "All" }, - ...domainOrder.map((domain) => ({ id: domain as DomainFilter, label: domainLabels[domain] })), -]; - -export function CalculatorsSearchPage() { - const [query, setQuery] = useState(""); - const [domain, setDomain] = useState("all"); +export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: string }) { + const router = useRouter(); + const searchCommand = useSearchCommand(); + const hydrated = useSyncExternalStore( + subscribeNoop, + () => true, + () => false, + ); + const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; + const normalizedQuery = normalizeCalculatorQuery(query); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); + const [selectedDomains, setSelectedDomains] = useState>(new Set()); + const [progress, setProgress] = useState("all"); + const [time, setTime] = useState("all"); const [density, setDensity] = useState("comfortable"); const [session, setSession] = useState({}); const [openId, setOpenId] = useState(null); - const trimmed = query.trim().toLowerCase(); - - const domainCounts = useMemo(() => { - const counts: Record = {}; - for (const calc of calculators) counts[calc.domain] = (counts[calc.domain] ?? 0) + 1; - return counts; - }, []); - - const results = useMemo( + const records = useMemo( () => - calculators - .filter((calc) => (domain === "all" || calc.domain === domain) && matches(calc, trimmed)) - .map((calc) => ({ calc, context: matchContext(calc, trimmed) })), - [domain, trimmed], + calculators.map((calc) => ({ + calc, + derived: deriveCalculator(calc, session[calc.id] ?? {}), + })), + [session], ); - - const inProgress = useMemo( + const filters = useMemo( + () => ({ domains: selectedDomains, progress, time }), + [progress, selectedDomains, time], + ); + const results = useMemo( () => - calculators - .map((calc) => ({ calc, derived: deriveCalculator(calc, session[calc.id] ?? {}) })) - .filter((entry) => entry.derived.started), - [session], + filterCalculatorRecords(records, query, filters).map((record) => ({ + ...record, + context: matchContext(record.calc, normalizedQuery), + })), + [filters, normalizedQuery, query, records], ); - + const inProgress = useMemo(() => records.filter((record) => record.derived.started), [records]); const activeCalc = openId ? calculators.find((calc) => calc.id === openId) : undefined; + const activeFilterCount = selectedDomains.size + (progress === "all" ? 0 : 1) + (time === "all" ? 0 : 1); useEffect(() => { if (!activeCalc) return; @@ -516,224 +308,192 @@ export function CalculatorsSearchPage() { }; }, [activeCalc]); - // The viewport frame owns the authoritative hide decision for its header and - // every portaled footer. Reuse that signal so slower hydration/RAF scheduling - // cannot let independently evaluated header and calculator reporters split. - // Keep local reporters only for the shell-less fallback used by isolated - // renders; browser phones scroll the document while standalone mode scrolls - // #main-content. - const frameScrollHidden = usePhoneFooterLayerScrollHidden(); - const useLocalScrollFallback = frameScrollHidden === undefined; - const scrollContainerRef = useRef(null); - useEffect(() => { - scrollContainerRef.current = document.querySelector("#main-content"); - }, []); - const innerFooterHidden = useHideOnScroll({ - containerRef: scrollContainerRef, - disabled: !useLocalScrollFallback, + function toggleDomain(domain: CalculatorDomain) { + setSelectedDomains((current) => { + const next = new Set(current); + if (next.has(domain)) next.delete(domain); + else next.add(domain); + return next; + }); + } + + function clearFilters() { + setSelectedDomains(new Set()); + setProgress("all"); + setTime("all"); + } + + const domainGroup = resultFilterFacetGroup({ + id: "clinical-domain", + label: "Clinical domain", + selected: selectedDomains, + options: domainOrder.map((domain) => { + const count = calculatorDomainCandidateCount(records, query, filters, domain); + return { + value: domain, + label: domainLabels[domain], + hint: String(count), + disabled: count === 0 && !selectedDomains.has(domain), + }; + }), + onToggle: toggleDomain, }); - const documentFooterHidden = useHideOnScroll({ - documentCollapseRootRef: scrollContainerRef, - disabled: !useLocalScrollFallback, + const progressGroup = resultFilterGroup({ + id: "session-progress", + label: "Session progress", + value: progress, + options: progressOptions.map((option) => { + const count = calculatorProgressCandidateCount(records, query, filters, option.value); + return { + ...option, + hint: String(count), + disabled: count === 0 && progress !== option.value, + }; + }), + onChange: setProgress, + note: "one only", + }); + const timeGroup = resultFilterGroup({ + id: "completion-time", + label: "Completion time", + value: time, + options: timeOptions.map((option) => { + const count = calculatorTimeCandidateCount(records, query, filters, option.value); + return { + ...option, + hint: String(count), + disabled: count === 0 && time !== option.value, + }; + }), + onChange: setTime, + note: "one only", }); - const footerHidden = frameScrollHidden ?? (innerFooterHidden || documentFooterHidden); - // Keep the phone dock visible while focused so scroll-hide cannot slide a - // focused input off-screen or mark it aria-hidden while still tabbable. - const [dockFocused, setDockFocused] = useState(false); - const dockHidden = footerHidden && !dockFocused; - const reserveTransitioning = useReserveTransitionMarker(dockHidden, activeCalc); - useEffect(() => { - if (!activeCalc) return; - // Submitting a focused dock input unmounts the dock before React is - // guaranteed to dispatch blur. Clear the latch after teardown so the dock - // can resume hide-on-scroll when the calculator sheet closes. This reset - // must survive a fast close: cancelling the microtask during effect cleanup - // can otherwise leave the remounted dock permanently focus-pinned. - queueMicrotask(() => { - setDockFocused(false); - }); - }, [activeCalc]); - - const compact = density === "compact"; - - const submitSearch = () => { - if (results.length === 1) setOpenId(results[0].calc.id); - }; - const resetSearch = () => { - setQuery(""); - setDomain("all"); - }; + const appliedFilters: AppliedFilterChip[] = [ + ...domainOrder + .filter((domain) => selectedDomains.has(domain)) + .map((domain) => ({ + id: `domain-${domain}`, + label: `Clinical domain: ${domainLabels[domain]}`, + onRemove: () => toggleDomain(domain), + })), + ...(progress === "all" + ? [] + : [ + { + id: `progress-${progress}`, + label: `Session progress: ${optionLabel(progressOptions, progress)}`, + onRemove: () => setProgress("all"), + }, + ]), + ...(time === "all" + ? [] + : [ + { + id: `time-${time}`, + label: `Completion time: ${optionLabel(timeOptions, time)}`, + onRemove: () => setTime("all"), + }, + ]), + ]; return ( <> - {/* Desktop: universal-style composer at the top, matching the site-wide - search header. Phones get the docked bottom composer below. */} -
- -
- -
-
- {filterChips.map((chip) => { - const active = domain === chip.id; - return ( - - ); - })} -
- - - Advanced filters are coming soon. - -
-
- } - summary={ - + <> + 0 ? clearFilters : undefined} + mobileControlsPlacement="inline" + mobileControls={ + setFilterOpen((current) => !current)} + /> + } + utilityControls={ + + setFilterOpen((current) => !current)} + /> + + + } + /> + setFilterOpen(false)} + panelId={filterPanelId} + testId="calculators-filter-sheet" + title="Filter calculators" + description="Choose any clinical domains, then narrow by session progress and completion time." + groups={[domainGroup, progressGroup, timeGroup]} + onClearAll={activeFilterCount > 0 ? clearFilters : undefined} + footerNote={`${results.length} showing`} + chromeResetKey={query} + /> + } sidebar={ <> - } sidebarMobile={ -
+
+
} > {results.length ? ( -
- {results.map(({ calc, context }) => ( +
+ {results.map(({ calc, derived, context }) => ( setOpenId(calc.id)} /> ))}
) : ( -
- - -

- No calculators match “{query.trim()}”. -

-

- Try a symptom (“hopeless”, “drinking”, “worry”) or clear the filters. -

- -
+ 0 ? clearFilters : undefined} + onClearSearch={() => router.push(appModeHomeHref("calculators", { focus: true }))} + onTryExample={(example) => router.push(appModeHomeHref("calculators", { query: example, run: true }))} + /> )} - {/* Phones: composer docks at the bottom, matching the site-wide composer - placement, and slides away on scroll-down in lockstep with the header. - Hidden while a calculator sheet is open. */} - {activeCalc ? null : ( - -
setDockFocused(true)} - onBlurCapture={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDockFocused(false); - }} - className={cn( - "phone-footer-layer answer-footer-search-dock answer-footer-search-edge inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.75rem+var(--safe-area-bottom))] pt-3 transition-[transform,opacity] motion-reduce:transition-none sm:hidden", - dockHidden - ? "pointer-events-none duration-[var(--duration-slow)] ease-[var(--ease-chrome-hide)]" - : "duration-[var(--duration-moderate)] ease-[var(--ease-chrome-reveal)]", - )} - aria-hidden={dockHidden} - inert={dockHidden || undefined} - > - - - )} - {activeCalc ? ( setSession((prev) => ({ ...prev, [activeCalc.id]: next }))} + onAnswersChange={(next) => setSession((current) => ({ ...current, [activeCalc.id]: next }))} onClose={() => setOpenId(null)} onOpenCalculator={setOpenId} /> diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index e5d2012b3..a47ccba15 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -84,6 +84,7 @@ const sidebarMoreModeIds = [ "dsm", "specifiers", "formulation", + "calculators", "therapy-compass", ] as const satisfies readonly AppModeId[]; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 888704d62..1e37cb3c4 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -638,8 +638,8 @@ function GlobalStandaloneSearchShellBody({ router.push(appModeHomeHref(mode, nextOptions)); } - function submitSearch() { - const trimmedQuery = query.trim(); + function submitSearch(queryOverride?: string) { + const trimmedQuery = (queryOverride ?? query).trim(); navigateToMode(searchMode, { query: trimmedQuery || undefined, run: Boolean(trimmedQuery), @@ -856,7 +856,9 @@ function GlobalStandaloneSearchShellBody({ onAsk={submitSearch} onClearQuery={() => { setQuery(""); - if (isStandaloneModeHome) navigateToMode(searchMode, { focus: true }); + if (isStandaloneModeHome || searchMode === "calculators") { + navigateToMode(searchMode, { focus: true }); + } }} onClearScope={() => undefined} onQueryModeChange={setQueryMode} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 3f874a69d..021e13312 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -114,8 +114,8 @@ const phoneModeGroups = [ { id: "care", label: "Care", - hint: "Medication, tools, therapy", - modeIds: ["prescribing", "tools", "therapy-compass", "factsheets"], + hint: "Medication, calculators, tools, therapy", + modeIds: ["prescribing", "calculators", "tools", "therapy-compass", "factsheets"], }, ] as const satisfies ReadonlyArray<{ id: string; @@ -247,7 +247,7 @@ export function MasterSearchHeader({ realDataReady: boolean; onQueryChange: (query: string) => void; onSearchModeChange: (mode: AppModeId) => void; - onAsk: () => void; + onAsk: (query?: string) => void; onClearQuery: () => void; onClearScope: () => void; onQueryModeChange: (mode: ClinicalQueryMode) => void; @@ -376,6 +376,7 @@ export function MasterSearchHeader({ selectedSearch.kind === "forms" || selectedSearch.kind === "services" || selectedSearch.kind === "tools" || + selectedSearch.kind === "calculators" || selectedSearch.kind === "favourites" || selectedSearch.kind === "specifiers" || selectedSearch.kind === "formulation" || @@ -659,9 +660,11 @@ export function MasterSearchHeader({ ? "formulation" : searchMode === "tools" ? "tools" - : searchMode === "factsheets" - ? "factsheets" - : "answer"; + : searchMode === "calculators" + ? "calculators" + : searchMode === "factsheets" + ? "factsheets" + : "answer"; const actionMenuItems = modeActionItemsFor(actionMenuSetId); const actionMenuButtonLabel = `Open ${selectedAppMode.label.toLowerCase()} options`; @@ -816,6 +819,10 @@ export function MasterSearchHeader({ onSearchModeChange("tools"); return; } + if (actionId === "calculators-browse") { + router.push("/calculators"); + return; + } if (actionId === "differentials-build") { onSearchModeChange("differentials"); onQueryChange(trimmedQuery || "acute confusion differential diagnosis"); diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index 037b21780..326655f79 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -63,14 +63,9 @@ export function isDocumentViewerOwnedRoute(pathname: string): boolean { return pathname !== "/documents/search"; } -/** Calculators owns its desktop top + phone bottom search composer. */ -export function isCalculatorsOwnedRoute(pathname: string): boolean { - return pathname === "/calculators" || pathname.startsWith("/calculators/"); -} - /** Routes that own a floating/page composer so the shell keeps only a zero pad. */ export function isPageOwnedComposerRoute(pathname: string): boolean { - return isDocumentViewerOwnedRoute(pathname) || isCalculatorsOwnedRoute(pathname); + return isDocumentViewerOwnedRoute(pathname); } /* @@ -124,7 +119,7 @@ export function resolveShellVisibleMobileComposerReserve(input: { patientDetailsAddonActive?: boolean; }): string { if (!input.shouldShowSearchComposer) { - // Page-owned composers (DocumentViewer, Calculators) manage their own dock + // Page-owned composers (DocumentViewer) manage their own dock // clearance; the shell keeps only the hidden-size pad. const pageOwned = input.pageOwnedComposerRoute ?? input.documentViewerOwnedRoute ?? false; return pageOwned ? mobileComposerHiddenReserve : mobileComposerIdleReserve; diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 0da42b6f8..dbe4a78a7 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -19,6 +19,7 @@ import { BookOpenText, TriangleAlert, CalendarDays, + Calculator, Check, ChevronDown, ChevronRight, @@ -57,6 +58,7 @@ export type ModeActionSetId = | "forms" | "favourites" | "tools" + | "calculators" | "differentials" | "dsm" | "specifiers" @@ -123,6 +125,7 @@ export type ModeActionId = | "medication-access" | "tools-browse" | "tools-new" + | "calculators-browse" | "differentials-build" | "differentials-criteria" | "differentials-documents" @@ -227,6 +230,15 @@ const modeActionSets = { }, { id: "tools-new", label: "New answer", description: "Clear the current thread", icon: Sparkles }, ], + calculators: [ + { + id: "calculators-browse", + label: "Browse calculators", + description: "Open the calculator catalogue", + icon: Calculator, + primary: true, + }, + ], differentials: [ { id: "differentials-build", diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 86c05f0fd..8c778bfdb 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -32,6 +32,7 @@ import { commandDropdownCanDisplay, commandDropdownMinimumWidthMediaQuery, commandDropdownPointerMediaQuery, + commandSurfaceRemoteSearchEnabled, differentialRedFlagTerms, filteredSuggestions, isFormCodeQuery, @@ -441,7 +442,7 @@ export function UniversalSearchCommandSurface({ dropdownOpen: boolean; onDropdownOpenChange: (open: boolean) => void; onQueryChange: (query: string) => void; - onSearch: () => void; + onSearch: (query?: string) => void; onPickRecent: (query: string) => void; onCrossMode: (modeId: AppModeId, query: string) => void; onRunModeAction?: (actionId: ModeActionId) => void; @@ -489,7 +490,7 @@ export function UniversalSearchCommandSurface({ // the palette surfaces every entity type, ordered by the server's intent-aware domainOrder. const universal = useUniversalSearch({ query: trimmedQuery, - enabled: dropdownOpen && dropdownDisplayable && Boolean(config), + enabled: dropdownOpen && dropdownDisplayable && commandSurfaceRemoteSearchEnabled(modeId), contextMode: modeId, }); const savedRegistryFavourites = useSavedRegistryFavourites().items; @@ -697,7 +698,7 @@ export function UniversalSearchCommandSurface({ onSelect: () => { onDropdownOpenChange(false); onQueryChange(suggestion.text); - onSearch(); + onSearch(suggestion.text); }, render: (active) => ( diff --git a/src/lib/app-mode-icons.ts b/src/lib/app-mode-icons.ts index 090584c44..0f91559cc 100644 --- a/src/lib/app-mode-icons.ts +++ b/src/lib/app-mode-icons.ts @@ -2,6 +2,7 @@ import { BookOpenCheck, BookOpenText, BrainCircuit, + Calculator, Compass, FileSignature, FileText, @@ -30,6 +31,7 @@ export const appModeIcons: Record = { formulation: Network, prescribing: Pill, tools: Wrench, + calculators: Calculator, "therapy-compass": Compass, factsheets: BookOpenText, }; diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 704ad37b7..1c74249d6 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -14,6 +14,7 @@ export const appModeIds = [ "formulation", "prescribing", "tools", + "calculators", "therapy-compass", "factsheets", ] as const; @@ -31,6 +32,7 @@ export type AppModeSearchKind = | "dsm" | "specifiers" | "formulation" + | "calculators" | "tools"; export type AppModeResultKind = AppModeSearchKind; @@ -336,6 +338,29 @@ export const appModeDefinitions = [ badgeLabel: null, }, }, + { + id: "calculators", + label: "Calculators", + description: "Source-cited psychiatry scores and clinical decision calculators", + href: "/calculators", + search: { + kind: "calculators", + placeholder: "Search calculators by scale, symptom, or indication...", + inputAriaLabel: "Search clinical calculators by scale, symptom, or indication", + submitIdleLabel: "Calculate", + submitBusyLabel: "Calculate", + submitAriaLabel: "Search clinical calculators", + emptyTitle: "Search clinical calculators", + readyTitle: "Find a clinical calculator", + progressLabel: "Searching the local calculator catalogue.", + resultKind: "calculators", + resultHeading: "Calculator matches", + resultsSurface: "results-band", + statusLabel: "Calculators", + nextStep: "Open a calculator to score it and review next actions", + badgeLabel: null, + }, + }, { id: "therapy-compass", label: "Therapy", @@ -429,6 +454,7 @@ const namespaceIsolatedModes = new Set([ "therapy-compass", "factsheets", "tools", + "calculators", ]); export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOptions = {}) { @@ -523,6 +549,7 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "dsm" || kind === "specifiers" || kind === "formulation" || + kind === "calculators" || kind === "tools" ); } diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index 48b959ab4..ac51ae962 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -57,10 +57,11 @@ export const modeSecondaryNavigationRegistry = { ], prescribing: [], tools: [], + calculators: [], // Inert: `PageSecondaryNavigation` early-returns on `/therapy-compass*`, and // the mode's live destination list is `useTherapyNavItems` in // `src/components/therapy-compass/nav.tsx`, which feeds the shared `ModeNav`. - // Kept because this registry is a 13-mode contract, not because it renders. + // Kept because this registry is a 14-mode contract, not because it renders. // Editing these entries changes nothing a user sees. "therapy-compass": [ { id: "search", label: "Search", action: "therapy-search" }, diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index 7c5c69098..506782033 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -9,6 +9,8 @@ export type SearchCommandSurfaceConfig = { examples: string[]; suggestions: CommandSuggestion[]; crossModes: AppModeId[]; + /** Defaults to true. Set false when a mode's search contract is entirely local. */ + remoteSearchEnabled?: boolean; }; export type CommandSurfacePlacement = "bottom-dock" | "inline"; @@ -141,12 +143,27 @@ const searchCommandSurfaceByMode: Partial([ "therapy-compass", "factsheets", "tools", + "calculators", ]); /** @@ -34,6 +35,7 @@ const standaloneModeHomePaths = new Set([ "/factsheets", "/therapy-compass", "/tools", + "/calculators", // Documents and Medication gained real homes when `/` became the single shared // home for every mode. Like the others they own an in-flow hero composer. "/documents", diff --git a/src/lib/search-shell-props.ts b/src/lib/search-shell-props.ts index 08b29bf50..3abf405a0 100644 --- a/src/lib/search-shell-props.ts +++ b/src/lib/search-shell-props.ts @@ -70,9 +70,8 @@ export function searchShellPropsForPathname(pathname: string): SearchShellPathPr return { initialMode: "tools", desktopSearchPlacement: "hero" }; } - // Calculators owns its search composer (desktop top + phone bottom dock). if (pathname.startsWith("/calculators")) { - return { initialMode: "tools", searchComposerVisible: false }; + return { initialMode: "calculators", desktopSearchPlacement: "hero" }; } if (pathname.startsWith("/therapy-compass")) { diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts index 2d26a6abc..fec7bfe77 100644 --- a/src/lib/ui-copy.ts +++ b/src/lib/ui-copy.ts @@ -52,6 +52,9 @@ export const sharedHomePresentation = { tools: { title: "Clinical Tools", }, + calculators: { + title: "Clinical Calculators", + }, "therapy-compass": { title: "Therapy Compass", }, diff --git a/src/lib/universal-search-mode-context.ts b/src/lib/universal-search-mode-context.ts index f921eb953..d90dcbe2b 100644 --- a/src/lib/universal-search-mode-context.ts +++ b/src/lib/universal-search-mode-context.ts @@ -13,6 +13,9 @@ const preferredDomainsByMode: Record { expect(universalSearchModeForDomain("therapies")).toBe("therapy-compass"); expect(universalSearchPreferredDomains("favourites")).toEqual([]); expect(universalSearchPreferredDomains("factsheets")).toEqual([]); + expect(universalSearchPreferredDomains("calculators")).toEqual([]); }); it("requires every mode to declare its search behavior and copy", () => { @@ -77,6 +78,17 @@ describe("app mode search contract", () => { expect(config.placeholder.toLowerCase()).toContain("tools"); }); + it("keeps calculators local and searchable as a first-class mode", () => { + const config = appModeSearchConfig("calculators"); + const mode = appModeDefinitions.find((definition) => definition.id === "calculators"); + + expect(isSearchableAppMode("calculators")).toBe(true); + expect(mode?.href).toBe("/calculators"); + expect(config.kind).toBe("calculators"); + expect(config.resultKind).toBe("calculators"); + expect(config.resultsSurface).toBe("results-band"); + }); + it("keeps services searchable through the shared dashboard composer", () => { const config = appModeSearchConfig("services"); const mode = appModeDefinitions.find((definition) => definition.id === "services"); @@ -236,6 +248,9 @@ describe("app mode search contract", () => { expect(appModeHomeHref("tools", { query: " medications ", run: true, focus: true })).toBe( "/tools?q=medications&focus=1&run=1", ); + expect(appModeHomeHref("calculators", { query: " PHQ-9 ", run: true, focus: true })).toBe( + "/calculators?q=PHQ-9&focus=1&run=1", + ); }); it("keeps active search context while routing from the shared composer", () => { @@ -270,6 +285,7 @@ describe("app mode search contract", () => { "formulation", "prescribing", "tools", + "calculators", "therapy-compass", "factsheets", ]), @@ -291,6 +307,7 @@ describe("app mode search contract", () => { expect(isAppModeVisible("formulation", "production")).toBe(true); expect(isAppModeVisible("prescribing", "production")).toBe(true); expect(isAppModeVisible("tools", "production")).toBe(true); + expect(isAppModeVisible("calculators", "production")).toBe(true); expect(isAppModeVisible("therapy-compass", "production")).toBe(true); expect(isAppModeVisible("factsheets", "production")).toBe(true); expect(productionModes).not.toContain("evidence"); @@ -303,6 +320,7 @@ describe("app mode search contract", () => { expect(productionModes).toContain("formulation"); expect(productionModes).toContain("prescribing"); expect(productionModes).toContain("tools"); + expect(productionModes).toContain("calculators"); expect(productionModes).toContain("therapy-compass"); expect(productionModes).toContain("factsheets"); expect(developmentModes).toEqual( @@ -318,6 +336,7 @@ describe("app mode search contract", () => { "formulation", "prescribing", "tools", + "calculators", "therapy-compass", "factsheets", ]), @@ -393,6 +412,7 @@ describe("app mode search contract", () => { "therapy-compass": "/therapy-compass?q=clozapine&run=1", // Tools has no search route by design: it filters its launcher in place. tools: "/tools?q=clozapine&run=1", + calculators: "/calculators?q=clozapine&run=1", }); }); diff --git a/tests/calculators-mode.dom.test.tsx b/tests/calculators-mode.dom.test.tsx new file mode 100644 index 000000000..9aabe09ca --- /dev/null +++ b/tests/calculators-mode.dom.test.tsx @@ -0,0 +1,240 @@ +/** @vitest-environment jsdom */ + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +const navigation = vi.hoisted(() => ({ + push: vi.fn(), + redirect: vi.fn(() => { + throw new Error("NEXT_REDIRECT"); + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: navigation.push }), + redirect: navigation.redirect, +})); + +import CalculatorsRoute from "@/app/(search-app)/calculators/page"; +import { + calculatorDomainCandidateCount, + calculatorProgressCandidateCount, + calculatorTimeCandidateCount, + filterCalculatorRecords, + type CalculatorFilterRecord, + type CalculatorFilterState, +} from "@/components/calculators/calculator-filters"; +import { calculators, type CalculatorFixture } from "@/components/calculators/calculator-fixtures"; +import { CalculatorsHomePage } from "@/components/calculators/home-page"; +import { CalculatorsSearchPage } from "@/components/calculators/search-page"; +import { deriveCalculator, type AnswerMap } from "@/components/calculators/calculator-ui"; +import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; + +function completeAnswers(calc: CalculatorFixture): AnswerMap { + return Object.fromEntries(calc.items.map((item) => [item.id, 0])); +} + +function recordsWithProgress(): CalculatorFilterRecord[] { + return calculators.map((calc) => { + const answers = calc.id === "gad7" ? completeAnswers(calc) : calc.id === "phq9" ? { p1: 1 } : {}; + return { calc, derived: deriveCalculator(calc, answers) }; + }); +} + +const emptyFilters = (): CalculatorFilterState => ({ + domains: new Set(), + progress: "all", + time: "all", +}); + +describe("calculator mode routing", () => { + it("renders the shared-composer home until a non-empty search is submitted", async () => { + const home = await CalculatorsRoute({ searchParams: Promise.resolve({}) }); + const emptyRun = await CalculatorsRoute({ searchParams: Promise.resolve({ run: "1", q: " " }) }); + const results = await CalculatorsRoute({ + searchParams: Promise.resolve({ run: "1", q: " depression " }), + }); + + expect(home.type).toBe(CalculatorsHomePage); + expect(emptyRun.type).toBe(CalculatorsHomePage); + expect(results.type).toBe(CalculatorsSearchPage); + expect(results.props.initialQuery).toBe("depression"); + }); + + it("normalizes the legacy query parameter to the canonical q URL", async () => { + navigation.redirect.mockClear(); + await expect( + CalculatorsRoute({ + searchParams: Promise.resolve({ run: "1", query: "PHQ-9", focus: "1" }), + }), + ).rejects.toThrow("NEXT_REDIRECT"); + expect(navigation.redirect).toHaveBeenCalledWith("/calculators?run=1&focus=1&q=PHQ-9"); + }); + + it("removes empty and redundant legacy query parameters", async () => { + navigation.redirect.mockClear(); + await expect(CalculatorsRoute({ searchParams: Promise.resolve({ query: " " }) })).rejects.toThrow( + "NEXT_REDIRECT", + ); + expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators"); + + await expect(CalculatorsRoute({ searchParams: Promise.resolve({ q: "GAD-7", query: "" }) })).rejects.toThrow( + "NEXT_REDIRECT", + ); + expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators?q=GAD-7"); + }); + + it("mounts the universal hero composer slot and canonical starter searches", () => { + const { container } = render(); + + expect(screen.getByRole("heading", { level: 1, name: "Clinical Calculators" })).toBeVisible(); + expect(container.querySelector(".mode-home-composer-slot")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Depression severity/ })).toHaveAttribute( + "href", + "/calculators?q=depression&run=1", + ); + expect(screen.getByRole("link", { name: "PHQ-9" })).toHaveAttribute("href", "/calculators?q=PHQ-9&run=1"); + }); +}); + +describe("calculator filter predicates", () => { + it("applies OR within domains and AND across domain, progress, time, and query", () => { + const records = recordsWithProgress(); + const filters: CalculatorFilterState = { + domains: new Set(["mood", "anxiety"]), + progress: "completed", + time: "quick", + }; + + expect(filterCalculatorRecords(records, "anxiety", filters).map(({ calc }) => calc.id)).toEqual(["gad7"]); + expect(filterCalculatorRecords(records, "depression", filters)).toEqual([]); + }); + + it("keeps not-started, in-progress, and completed boundaries exclusive", () => { + const records = recordsWithProgress(); + const states = ["not-started", "in-progress", "completed"] as const; + const ids = Object.fromEntries( + states.map((progress) => [ + progress, + filterCalculatorRecords(records, "", { ...emptyFilters(), progress }).map(({ calc }) => calc.id), + ]), + ); + + expect(ids["in-progress"]).toEqual(["phq9"]); + expect(ids.completed).toEqual(["gad7"]); + expect(ids["not-started"]).not.toContain("phq9"); + expect(ids["not-started"]).not.toContain("gad7"); + expect(new Set(Object.values(ids).flat()).size).toBe(calculators.length); + }); + + it("buckets completion time by the fixture's numeric maximum", () => { + const records = recordsWithProgress(); + const idsFor = (time: "quick" | "standard" | "extended") => + filterCalculatorRecords(records, "", { ...emptyFilters(), time }).map(({ calc }) => calc.id); + + expect(idsFor("quick")).toEqual(["gad7", "cage", "auditc", "sadpersons"]); + expect(idsFor("standard")).toEqual(["phq9", "k10", "mdq"]); + expect(idsFor("extended")).toEqual(["ybocs"]); + }); + + it("derives candidate counts from the same predicates", () => { + const records = recordsWithProgress(); + const filters: CalculatorFilterState = { + domains: new Set(["mood"]), + progress: "all", + time: "all", + }; + + expect(calculatorDomainCandidateCount(records, "", filters, "anxiety")).toBe(4); + expect(calculatorProgressCandidateCount(records, "", filters, "in-progress")).toBe(1); + expect(calculatorTimeCandidateCount(records, "", filters, "quick")).toBe(0); + }); +}); + +describe("calculator results surface", () => { + it("uses the shared results band and filter sheet without a page-owned composer", async () => { + const user = userEvent.setup(); + const { container } = render(); + + expect(screen.getByRole("heading", { level: 1, name: "depression" })).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("2 calculators"); + expect(container.querySelector('[data-testid="calculators-phone-dock"]')).toBeNull(); + + await user.click(screen.getByTestId("calculators-filter-trigger-phone")); + const sheet = screen.getByTestId("calculators-filter-sheet"); + expect(within(sheet).getByRole("group", { name: "Clinical domain" })).toBeVisible(); + expect(within(sheet).getByRole("radiogroup", { name: "Session progress" })).toBeVisible(); + expect(within(sheet).getByRole("radiogroup", { name: "Completion time" })).toBeVisible(); + }); + + it("labels applied filters, removes one chip, and clears filters without clearing the query", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("calculators-filter-trigger-phone")); + const sheet = screen.getByTestId("calculators-filter-sheet"); + await user.click(within(sheet).getByRole("button", { name: /Mood/ })); + expect(screen.getByRole("button", { name: "Remove Clinical domain: Mood filter" })).toBeVisible(); + expect(screen.getByRole("heading", { level: 1, name: "depression" })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Remove Clinical domain: Mood filter" })); + expect(screen.queryByRole("button", { name: "Remove Clinical domain: Mood filter" })).toBeNull(); + + await user.click(within(sheet).getByRole("button", { name: /Mood/ })); + await user.click(screen.getByTestId("calculators-filter-sheet-clear")); + expect(screen.queryByRole("button", { name: "Remove Clinical domain: Mood filter" })).toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "depression" })).toBeVisible(); + }); + + it("distinguishes filtered and query-only empty recovery", async () => { + const user = userEvent.setup(); + const { rerender } = render( + + + , + ); + + await user.click(screen.getByTestId("calculators-filter-trigger-phone")); + await user.click(within(screen.getByTestId("calculators-filter-sheet")).getByRole("button", { name: /Mood/ })); + rerender( + + + , + ); + expect(screen.getByText("No calculators match the selected filter")).toBeVisible(); + expect(screen.getByTestId("search-results-empty-remove-filter")).toBeVisible(); + + await user.click(screen.getByTestId("calculators-filter-sheet-clear")); + rerender( + + + , + ); + expect(screen.getByText("No matches for “no-such-calculator”")).toBeVisible(); + await user.click(screen.getByTestId("search-results-empty-clear-search")); + expect(navigation.push).toHaveBeenCalledWith("/calculators?focus=1"); + }); + + it("opens, updates, completes, closes, and resumes a scoring session", async () => { + const user = userEvent.setup(); + Element.prototype.scrollTo = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /^Open PHQ-9/ })); + let dialog = screen.getByRole("dialog", { name: "PHQ-9 calculator" }); + const firstItem = within(dialog).getByRole("group", { name: "Little interest or pleasure in doing things" }); + await user.click(within(firstItem).getByRole("button", { name: "Several days (1 point)" })); + expect(within(dialog).getAllByText("1 of 9 answered")[0]).toBeVisible(); + + for (const option of within(dialog).getAllByRole("button", { name: "Not at all (0 points)" })) { + await user.click(option); + } + expect(within(dialog).getAllByText("9 of 9 answered")[0]).toBeVisible(); + + await user.click(within(dialog).getByRole("button", { name: /^Close$/ })); + await user.click(screen.getAllByRole("button", { name: /^Open PHQ-9/ })[0]); + dialog = screen.getByRole("dialog", { name: "PHQ-9 calculator" }); + expect(within(dialog).getAllByText("9 of 9 answered")[0]).toBeVisible(); + }); +}); diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 3ce7fc070..2e17cfa37 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -436,7 +436,7 @@ describe("shared header hide/reveal wiring", () => { it("uses one adaptive phone footer positioning owner", () => { expect(headerSource).toContain("phone-footer-layer"); expect(documentViewerSource).toContain("phone-footer-layer document-viewer-composer"); - expect(calculatorSearchSource).toContain("phone-footer-layer answer-footer-search-dock"); + expect(calculatorSearchSource).not.toContain("phone-footer-layer"); }); it("exposes one stable diagnostic contract across each phone chrome owner", () => { @@ -464,20 +464,18 @@ describe("shared header hide/reveal wiring", () => { expect(phoneFooterPortalSource).toContain("export function PhoneFooterLayerFrame"); expect(shellSource).toContain(""); + expect(calculatorSearchSource).not.toContain(""); expect(documentViewerSource).toContain(""); expect(differentialPresentationSource).toContain(""); expect(differentialPresentationSource).toContain('data-testid="differential-presentation-phone-footer"'); }); - it("shares the frame's authoritative scroll decision with the calculator footer", () => { + it("keeps calculator results on the shell's authoritative scroll decision", () => { expect(phoneFooterPortalSource).toContain("export function usePhoneFooterLayerScrollHidden"); expect(shellSource).toContain("scrollHidden={chromeScrollHide.hidden}"); expect(dashboardSource).toContain("scrollHidden={chromeScrollHidden}"); - expect(calculatorSearchSource).toContain("const frameScrollHidden = usePhoneFooterLayerScrollHidden()"); - expect(calculatorSearchSource).toContain( - "const footerHidden = frameScrollHidden ?? (innerFooterHidden || documentFooterHidden)", - ); + expect(calculatorSearchSource).not.toContain("usePhoneFooterLayerScrollHidden"); + expect(calculatorSearchSource).not.toContain("useHideOnScroll"); }); it("releases the phone top safe-area with hidden chrome while retaining the wide inset", () => { diff --git a/tests/helpers/phone-scroll.ts b/tests/helpers/phone-scroll.ts index 20fe66f36..317a66a3b 100644 --- a/tests/helpers/phone-scroll.ts +++ b/tests/helpers/phone-scroll.ts @@ -36,6 +36,7 @@ export const modeHomeRoutes = [ "/formulation", "/dsm", "/tools", + "/calculators", "/differentials", "/specifiers", "/factsheets", @@ -52,6 +53,7 @@ export const longRoutes = [ "/formulation/worry", "/formulation/builder?mechanism=rumination&template=5Ps", "/documents/search", + "/calculators?q=depression&run=1", // Demo-corpus document detail: DocumentViewer owns its composer here, and its // scroll container binding has its own failure mode (stale #main-content). "/documents/11111111-1111-4111-8111-111111111111?page=1", @@ -69,6 +71,7 @@ export const appModeHeaderRoutes = [ { mode: "Formulation", route: "/formulation" }, { mode: "Medication", route: "/?mode=prescribing" }, { mode: "Tools", route: "/tools" }, + { mode: "Calculators", route: "/calculators" }, { mode: "Therapy", route: "/therapy-compass" }, { mode: "Factsheets", route: "/factsheets" }, ]; @@ -122,14 +125,6 @@ export const pageOwnedHeaderRoutes = [ ]; export const standalonePageOwnedFooterRoutes = [ - { - name: "calculator composer", - route: "/calculators", - selector: '[data-testid="calculators-phone-dock"]', - focusSelector: 'input[aria-label="Search calculators"]', - reserveSelector: '[data-testid="calculators-search-page"]', - flushBottom: true, - }, { name: "document composer", route: "/documents/11111111-1111-4111-8111-111111111111?page=1", diff --git a/tests/mobile-chrome-paint-contract.test.ts b/tests/mobile-chrome-paint-contract.test.ts index 50ef4249b..2306b6bfc 100644 --- a/tests/mobile-chrome-paint-contract.test.ts +++ b/tests/mobile-chrome-paint-contract.test.ts @@ -57,15 +57,11 @@ describe("mobile chrome paint baseline", () => { } }); - it("registers the calculator page reserve with the shared hide and transition contracts", () => { - expect(calculatorsSource).toContain("useReserveTransitionMarker(dockHidden, activeCalc)"); - expect(calculatorsSource).toContain("if (!activeCalc) return;"); - expect(calculatorsSource).toContain("queueMicrotask(() =>"); - expect(calculatorsSource).toContain("setDockFocused(false)"); - expect(calculatorsSource).not.toContain("if (!cancelled) setDockFocused(false)"); - expect(calculatorsSource).toContain('reserveOwner="calculator"'); - expect(calculatorsSource).toContain('reserveHiddenPad="0rem"'); - expect(calculatorsSource).toContain("reserveTransitioning={reserveTransitioning}"); + it("keeps calculator results free of a second phone dock and page reserve", () => { + expect(calculatorsSource).not.toContain("useReserveTransitionMarker"); + expect(calculatorsSource).not.toContain('reserveOwner="calculator"'); + expect(calculatorsSource).not.toContain('data-testid="calculators-phone-dock"'); + expect(calculatorsSource).not.toContain("PhoneFooterLayerPortal"); expect(globalStylesSource).toContain('[data-reserve-owner][data-reserve-transitioning="true"]'); expect(scrollHideSource).toContain('querySelectorAll("[data-reserve-owner]")'); expect(scrollHideSource).toContain("dataset.reserveHiddenPad"); diff --git a/tests/mobile-composer-reserve.test.ts b/tests/mobile-composer-reserve.test.ts index f2756d62d..91d5ecd7b 100644 --- a/tests/mobile-composer-reserve.test.ts +++ b/tests/mobile-composer-reserve.test.ts @@ -4,7 +4,6 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { - isCalculatorsOwnedRoute, isDocumentViewerOwnedRoute, isPageOwnedComposerRoute, mobileComposerDifferentialsCompareReserve, @@ -205,11 +204,9 @@ describe("mobile composer reserve contract", () => { expect(isDocumentViewerOwnedRoute("/forms")).toBe(false); }); - it("classifies calculators as a page-owned composer route", () => { - expect(isCalculatorsOwnedRoute("/calculators")).toBe(true); - expect(isCalculatorsOwnedRoute("/calculators/phq-9")).toBe(true); - expect(isCalculatorsOwnedRoute("/tools")).toBe(false); - expect(isPageOwnedComposerRoute("/calculators")).toBe(true); + it("keeps calculators shell-owned and document viewers page-owned", () => { + expect(isPageOwnedComposerRoute("/calculators")).toBe(false); + expect(isPageOwnedComposerRoute("/calculators/phq-9")).toBe(false); expect(isPageOwnedComposerRoute("/documents/source")).toBe(true); expect(isPageOwnedComposerRoute("/tools")).toBe(false); expect( diff --git a/tests/mode-menu-prefetch.dom.test.tsx b/tests/mode-menu-prefetch.dom.test.tsx index 1bc0a6472..1a12cf09d 100644 --- a/tests/mode-menu-prefetch.dom.test.tsx +++ b/tests/mode-menu-prefetch.dom.test.tsx @@ -2,6 +2,7 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; @@ -89,6 +90,72 @@ describe("mode menu destination prefetch", () => { window.localStorage.clear(); }); + it("keeps calculator submission enabled when document data is unavailable", async () => { + const user = userEvent.setup(); + const onAsk = vi.fn(); + + render( + , + ); + + const submit = screen.getByRole("button", { name: "Search clinical calculators" }); + expect(submit).toBeEnabled(); + await user.click(submit); + expect(onAsk).toHaveBeenCalledTimes(1); + }); + + it("submits the selected calculator suggestion rather than the previous query state", async () => { + const user = userEvent.setup(); + const onAsk = vi.fn(); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })), + ); + + try { + function CalculatorHeader() { + const [query, setQuery] = useState("depression"); + return ( + + ); + } + + render(); + const input = screen.getByTestId("global-search-input"); + await user.clear(input); + await user.type(input, "depression"); + await user.click(await screen.findByRole("option", { name: /depression severity.*PHQ-9/i })); + + expect(onAsk).toHaveBeenCalledWith("depression severity"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("shows calculator-specific actions instead of Answer actions", async () => { + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole("button", { name: "Open calculators options" })); + + const actions = await screen.findByRole("menu", { name: "Useful actions" }); + expect(within(actions).getByRole("menuitem", { name: "Browse calculators" })).toBeVisible(); + expect(within(actions).queryByRole("menuitem", { name: "New question" })).toBeNull(); + expect(within(actions).queryByRole("menuitem", { name: "Add document" })).toBeNull(); + }); + it("prefetches the shared-home selection URL when the user points at a mode", async () => { const user = userEvent.setup(); const documents = guestModeHomes().find((mode) => mode.id === "documents"); diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 597617e00..bb5af111a 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -11,7 +11,7 @@ import { routedModeSecondaryNavigationCount, } from "@/lib/mode-secondary-navigation"; -/** Seven modes intentionally register no destinations at all — see `emptyRegistryModes`. */ +/** Eight modes intentionally register no destinations at all — see `emptyRegistryModes`. */ const expectedLabels: Record = { answer: [], documents: [], @@ -24,6 +24,7 @@ const expectedLabels: Record = { formulation: ["Find", "Build", "Compare", "Map"], prescribing: [], tools: [], + calculators: [], "therapy-compass": ["Search", "Recommend", "Compare", "Pathways", "Brief Intervention", "Patient Sheets"], factsheets: ["Topics", "Search"], }; @@ -40,6 +41,7 @@ const cleanLandingPath: Record = { formulation: "/formulation", prescribing: "/medications", tools: "/tools", + calculators: "/calculators", "therapy-compass": "/therapy-compass", factsheets: "/factsheets", }; @@ -59,12 +61,13 @@ const emptyRegistryModes = [ "favourites", "prescribing", "tools", + "calculators", ] as const satisfies readonly AppModeId[]; describe("mode secondary navigation registry", () => { - it("covers all 13 modes with the approved destinations and no Home item", () => { + it("covers all 14 modes with the approved destinations and no Home item", () => { expect(Object.keys(modeSecondaryNavigationRegistry).sort()).toEqual([...appModeIds].sort()); - expect(appModeIds).toHaveLength(13); + expect(appModeIds).toHaveLength(14); for (const modeId of appModeIds) { const labels = modeSecondaryNavigationRegistry[modeId].map((item) => item.label); @@ -73,7 +76,7 @@ describe("mode secondary navigation registry", () => { } }); - it("registers no destinations at all for the seven single-surface modes", () => { + it("registers no destinations at all for the eight single-surface modes", () => { // Empty is a real answer, pinned rather than left incidental: a future edit // that re-adds a lone focus-the-composer button should have to argue with // this test rather than slip back in. diff --git a/tests/search-command-surface.test.ts b/tests/search-command-surface.test.ts index b2d351ba3..86bef4ef4 100644 --- a/tests/search-command-surface.test.ts +++ b/tests/search-command-surface.test.ts @@ -6,6 +6,7 @@ import { commandDropdownCanDisplay, commandDropdownMinimumWidthMediaQuery, commandDropdownPointerMediaQuery, + commandSurfaceRemoteSearchEnabled, differentialRedFlagTerms, filteredSuggestions, isFormCodeQuery, @@ -46,6 +47,12 @@ describe("search command surface", () => { expect(specifiers?.crossModes).toContain("formulation"); }); + it("keeps calculator suggestions local while preserving remote typeahead elsewhere", () => { + expect(commandSurfaceRemoteSearchEnabled("calculators")).toBe(false); + expect(commandSurfaceRemoteSearchEnabled("documents")).toBe(true); + expect(commandSurfaceRemoteSearchEnabled("therapy-compass")).toBe(false); + }); + it("detects form code queries", () => { expect(isFormCodeQuery("form 3A")).toBe(true); expect(isFormCodeQuery("form 12")).toBe(true); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index 4dceed068..b1b8f1db6 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -23,6 +23,7 @@ describe("shared-search route ownership", () => { "formulation", "therapy-compass", "tools", + "calculators", ] as const) { expect(shouldRenderDashboardSearch({ hasSubmittedSearch: true, mode, pathname: `/${mode}` })).toBe(false); } @@ -65,6 +66,7 @@ describe("shared-search route ownership", () => { "/factsheets", "/therapy-compass", "/tools", + "/calculators", "/documents", "/medications", ]) { diff --git a/tests/search-shell-props.test.ts b/tests/search-shell-props.test.ts index 9f4d247dc..08299a4bd 100644 --- a/tests/search-shell-props.test.ts +++ b/tests/search-shell-props.test.ts @@ -35,10 +35,10 @@ describe("searchShellPropsForPathname", () => { expect(searchShellPropsForPathname("/")).toEqual({ initialMode: "answer" }); }); - it("hides the shell composer on calculators (page-owned)", () => { + it("assigns calculator home and results search to the shared shell", () => { expect(searchShellPropsForPathname("/calculators")).toEqual({ - initialMode: "tools", - searchComposerVisible: false, + initialMode: "calculators", + desktopSearchPlacement: "hero", }); }); }); diff --git a/tests/shared-home-empty-state.dom.test.tsx b/tests/shared-home-empty-state.dom.test.tsx index a573e0523..4dec57d65 100644 --- a/tests/shared-home-empty-state.dom.test.tsx +++ b/tests/shared-home-empty-state.dom.test.tsx @@ -66,6 +66,11 @@ const expectedPresentations = [ title: "Clinical Tools", iconClass: "lucide-wrench", }, + { + modeId: "calculators", + title: "Clinical Calculators", + iconClass: "lucide-calculator", + }, { modeId: "therapy-compass", title: "Therapy Compass", diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index 20bd27e86..1fa4b81e7 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -215,7 +215,7 @@ describe("overlay and global CSS contracts", () => { expect(browserMediaBlock).toContain("var(--mobile-composer-reserve, 0rem)"); expect(masterSearchHeaderSource).toContain("phone-footer-layer"); expect(documentViewerSource).toContain("phone-footer-layer document-viewer-composer"); - expect(calculatorSearchSource).toContain("phone-footer-layer answer-footer-search-dock"); + expect(calculatorSearchSource).not.toContain("phone-footer-layer"); expect(differentialPresentationSource).toContain("phone-footer-layer inset-x-0 bottom-0"); expect(differentialPresentationSource).not.toContain('className="fixed inset-x-0 bottom-0'); expect(globalSearchShellSource).toContain("phone-viewport-shell"); diff --git a/tests/ui-phone-scroll-page-owned.spec.ts b/tests/ui-phone-scroll-page-owned.spec.ts index b147b8e62..baffbede3 100644 --- a/tests/ui-phone-scroll-page-owned.spec.ts +++ b/tests/ui-phone-scroll-page-owned.spec.ts @@ -17,7 +17,7 @@ import { expectSingleSettledOwner } from "./playwright-settlement"; /** * Page-owned phone chrome: the document viewer's own composer, the standalone - * frame-owned footers, the calculators dock, and the Services result canvas. + * frame-owned footers and the Services result canvas. * * These surfaces own their composer instead of using the shell's, so their * hide/reveal and reserve behaviour is independent of the shared header swept in @@ -837,10 +837,20 @@ test("Services results keep a continuous browser viewport after shared chrome re ); }); -test("calculator results stay inside narrow phone viewports", async ({ page }) => { - for (const width of [320, 390]) { - await page.setViewportSize({ width, height: 844 }); - await gotoPhoneSurface(page, "/calculators", 112); +test("calculator results stay usable across the responsive and accessibility matrix", async ({ page }) => { + const viewports = [ + { width: 320, height: 740 }, + { width: 390, height: 844 }, + { width: 639, height: 900 }, + { width: 768, height: 1024 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, + ] as const; + + for (const { width, height } of viewports) { + await page.emulateMedia({ colorScheme: "light", forcedColors: "none", reducedMotion: "no-preference" }); + await page.setViewportSize({ width, height }); + await gotoPhoneSurface(page, "/calculators?q=depression&run=1", 112); const pageSurface = page.getByTestId("calculators-search-page"); const geometry = await pageSurface.evaluate((element) => ({ @@ -851,10 +861,10 @@ test("calculator results stay inside narrow phone viewports", async ({ page }) = geometry.clientWidth + 1, ); - for (const control of [ - page.getByRole("button", { name: "Filters" }), - page.getByRole("button", { name: /^Open PHQ-9/ }), - ]) { + const filterTrigger = page.getByTestId( + width < 640 ? "calculators-filter-trigger-phone" : "calculators-filter-trigger-desktop", + ); + for (const control of [filterTrigger, page.getByRole("button", { name: /^Open PHQ-9/ })]) { const box = await control.boundingBox(); expect(box, `${width}px calculator control should be rendered`).not.toBeNull(); expect(box!.x, `${width}px calculator control starts inside the viewport`).toBeGreaterThanOrEqual(0); @@ -862,351 +872,35 @@ test("calculator results stay inside narrow phone viewports", async ({ page }) = width + 1, ); } - } -}); - -test("calculators page-owned phone dock uses localized glass and releases its reserve when hidden", async ({ - page, -}) => { - await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - - const dock = page.getByTestId("calculators-phone-dock"); - const pageSurface = page.getByTestId("calculators-search-page"); - await expect(dock).toBeVisible(); - - const visible = await dock.evaluate((element) => { - const backdrop = element.querySelector(".answer-footer-search-backdrop"); - const dockRect = element.getBoundingClientRect(); - const backdropStyle = backdrop ? getComputedStyle(backdrop) : null; - return { - background: getComputedStyle(element).backgroundColor, - left: dockRect.left, - right: dockRect.right, - bottom: dockRect.bottom, - backdropDisplay: backdropStyle?.display ?? "missing", - backdropPosition: backdropStyle?.position ?? "missing", - backdropPaint: backdropStyle?.backgroundImage ?? "", - backdropHasTranslucentStop: /transparent|\/\s*(?:0?\.)\d+/.test(backdropStyle?.backgroundImage ?? ""), - }; - }); - expect(visible.background).toBe("rgba(0, 0, 0, 0)"); - expect(visible.left).toBeCloseTo(0, 0); - expect(visible.right).toBeCloseTo(phoneViewport.width, 0); - expect(visible.bottom).toBeCloseTo(phoneViewport.height, 0); - expect(visible.backdropDisplay).toBe("block"); - expect(visible.backdropPosition).toBe("absolute"); - expect(visible.backdropPaint).toContain("gradient"); - expect(visible.backdropHasTranslucentStop).toBe(true); - await expect - .poll(async () => - Number.parseFloat(await pageSurface.evaluate((element) => getComputedStyle(element).paddingBottom)), - ) - .toBeGreaterThan(112); - - // This journey owns the visible/hidden paint contract. Give it explicit - // runway so the separate near-bottom tests remain the sole owner of the - // intentional anti-clamp behavior on naturally short calculator pages. - await addPhoneScrollRunway(page); - const geometry = await readGeometry(page); - await dragScrollBy(page, Math.min(Math.max(geometry.maxOffset, 500), 900), 24); - await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); - await page.waitForTimeout(300); - - const hidden = await dock.evaluate((element) => { - const rect = element.getBoundingClientRect(); - return { opacity: getComputedStyle(element).opacity, top: rect.top, viewportHeight: window.innerHeight }; - }); - expect(hidden.opacity).toBe("0"); - expect(hidden.top).toBeGreaterThanOrEqual(hidden.viewportHeight - 1); - await expect - .poll(async () => - Number.parseFloat(await pageSurface.evaluate((element) => getComputedStyle(element).paddingBottom)), - ) - .toBeLessThanOrEqual(1); - - await dragScrollBy(page, -48, 8); - await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); -}); - -test("calculator dock clears its focus pin after a focused submit opens and closes a sheet", async ({ page }) => { - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - - const dock = page.getByTestId("calculators-phone-dock"); - const input = dock.getByRole("searchbox", { name: "Search calculators" }); - await input.fill("PHQ-9"); - await input.press("Enter"); - await expect(page.getByRole("button", { name: "Close", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Close", exact: true }).click(); - await expect(dock).toBeVisible(); - await expect(input).not.toBeFocused(); - - await addPhoneScrollRunway(page); - await dragScrollUntilHidden(page, 900, 24); - await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); -}); - -test("page-owned focus clearance places a below-fold calculator control above the visible dock", async ({ page }) => { - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - await expect(page.getByTestId("calculators-phone-dock")).toBeVisible(); - - const geometry = await page.evaluate(() => { - const owner = document.querySelector('[data-reserve-owner="calculator"]'); - const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); - if (!owner || !dock) throw new Error("calculator focus-clearance owners were not rendered"); - const runway = document.createElement("div"); - runway.style.height = "1400px"; - runway.setAttribute("aria-hidden", "true"); - const target = document.createElement("button"); - target.type = "button"; - target.textContent = "Focus clearance probe"; - target.dataset.testid = "phone-focus-clearance-probe"; - const tail = document.createElement("div"); - tail.style.height = "400px"; - tail.setAttribute("aria-hidden", "true"); - owner.append(runway, target, tail); - window.scrollTo(0, 0); - target.focus({ preventScroll: true }); - target.scrollIntoView({ block: "nearest" }); - const targetRect = target.getBoundingClientRect(); - const dockRect = dock.getBoundingClientRect(); - return { - targetBottom: targetRect.bottom, - dockTop: dockRect.top, - scrollMarginBottom: Number.parseFloat(getComputedStyle(target).scrollMarginBottom), - dockHidden: dock.getAttribute("data-scroll-hidden") === "true", - }; - }); - - expect(geometry.dockHidden, "measure before hide-on-scroll can release the visible dock").toBe(false); - expect(geometry.scrollMarginBottom, "the page-owned reserve must reach focused descendants").toBeGreaterThan(100); - expect(geometry.targetBottom, "focused content must land above the visible page-owned dock").toBeLessThanOrEqual( - geometry.dockTop + 1, - ); -}); -// Renamed 2026-07-30: the old title said "96px of near-bottom runway", but 96 is -// `collapseRunwaySlack`, which only exists on the in-flow branch of -// `computeScrollHideUpdate`. Reserve-only overlay has no slack term at all — its -// near-bottom refusal is `offset <= postCollapseMaxOffset + bottomClampTolerance`. -// One test covers both owners, so the title names the behaviour rather than one -// motion's constant. No flake-ledger or allowlist entry referenced the old name. -test("calculator combined chrome refuses a near-bottom hide that would clamp the reader", async ({ page }) => { - await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - await addPhoneScrollRunway(page); - - const { frames, diag } = await page.evaluate(async () => { - const main = document.getElementById("main-content"); - const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); - const reserve = document.querySelector('[data-testid="calculators-search-page"]'); - if (!main || !dock || !reserve) throw new Error("calculator reserve geometry was not rendered"); - const mainOwnsScroll = - /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; - const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); - const read = () => ({ - dockHidden: dock.getAttribute("data-scroll-hidden") === "true", - headerHidden: - document.querySelector('[data-testid="universal-header-collapse"]')?.getAttribute("data-scroll-hidden") === - "true", - scrollTop: scrollOwner.scrollTop, - maxOffset: scrollOwner.scrollHeight - scrollOwner.clientHeight, - }); - const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); - const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); - // Match `readChromeCollapseMetrics`: overlay charges zero top release, so - // the near-bottom gate is reserve-only. Including the still-present header - // height here overstates the budget and scrolls into a zone where - // reserve-only policy *allows* hide — the opposite of this regression guard. - const phoneOverlayMotion = collapse?.dataset.phoneMotion === "overlay"; - const headerRelease = - !phoneOverlayMotion && collapse && getComputedStyle(collapse).display === "grid" - ? collapse.getBoundingClientRect().height - : 0; - const safeAreaRelease = headerRelease > 0 && safeArea ? safeArea.getBoundingClientRect().height : 0; - const reserveRelease = Math.max(0, Number.parseFloat(getComputedStyle(reserve).paddingBottom) || 0); - const collapseBudget = headerRelease + safeAreaRelease + reserveRelease; - // Both target offsets are derived from `computeScrollHideUpdate`'s own - // refusal clauses (use-hide-on-scroll.ts) rather than from a chosen number, - // because the two motions refuse for genuinely different reasons: - // - // in-flow refuse while runwayAfterCollapse <= revealIntentDistance - // + collapseRunwaySlack (12 + 96) - // reserve-only refuse while offset > postCollapseMaxOffset - // + bottomClampTolerance - // - // Mirrored constants — keep in step with use-hide-on-scroll.ts. - const revealIntentDistance = 12; - const collapseRunwaySlack = 96; - const bottomClampTolerance = 1; - const maxOffset = Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight); - const postCollapseMaxOffset = Math.max(0, maxOffset - collapseBudget); - // Reserve-only: land strictly inside the refusal band but not at the very - // bottom, so this stays a boundary case rather than the trivial one. - const overlayTarget = Math.min( - maxOffset, - postCollapseMaxOffset + bottomClampTolerance + Math.max(1, Math.round(reserveRelease / 3)), - ); - const inFlowTarget = Math.max(0, maxOffset - collapseBudget - collapseRunwaySlack); - scrollOwner.scrollTop = phoneOverlayMotion ? overlayTarget : inFlowTarget; - // Capture the offset we actually landed on BEFORE dispatching, and never - // re-read it afterwards. If the policy regresses and the hide is wrongly - // allowed, the reserve collapses, maxOffset shrinks and the browser clamps - // scrollTop — so a post-loop read reports the offset after the bug rather - // than the offset under test. Asserting the band against that clamped value - // made a policy regression surface as "the test setup is wrong", which is - // the opposite of a useful failure. Found by removing the refusal clause and - // watching this test fail for the wrong reason (2026-07-30). - const targetScrollTop = scrollOwner.scrollTop; - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - const frames = [read()]; - for (let index = 0; index < 18; index += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - frames.push(read()); - } - return { - frames, - diag: { - phoneOverlayMotion, - reserveRelease, - collapseBudget, - maxOffset, - postCollapseMaxOffset, - targetScrollTop, - // The other reserve-only clause. If the post-collapse range were shorter - // than this, hide would be refused for being too short overall and the - // near-bottom clause would never be reached — the test would pass - // without testing anything. - minimumRangeForHide: 8 + 24, - inFlowRunwayAfterCollapse: maxOffset - targetScrollTop - collapseBudget, - inFlowRefusalCeiling: revealIntentDistance + collapseRunwaySlack, - }, - }; - }); - - // Non-vacuity first. "Chrome stayed visible" is only evidence of a refusal if - // the scroll position actually sat inside the refusal band AND the other - // clauses were satisfied — otherwise chrome stays visible for an unrelated - // reason and this test silently guards nothing. Added 2026-07-30: the overlay - // branch previously used chosen offsets, so nothing checked that it landed - // anywhere meaningful. - expect(diag.collapseBudget, "chrome must have geometry to release, or nothing can be refused").toBeGreaterThan(1); - if (diag.phoneOverlayMotion) { - expect( - diag.postCollapseMaxOffset, - "post-collapse range must clear the short-range clause, so the near-bottom clause is what refuses", - ).toBeGreaterThanOrEqual(diag.minimumRangeForHide); - expect(diag.targetScrollTop, "reserve-only offset must sit inside the near-bottom refusal band").toBeGreaterThan( - diag.postCollapseMaxOffset + 1, - ); - } else { - expect( - diag.inFlowRunwayAfterCollapse, - "in-flow runway after collapse must sit inside the slack refusal band", - ).toBeLessThanOrEqual(diag.inFlowRefusalCeiling); - } - - expect(frames.every((frame) => !frame.headerHidden && !frame.dockHidden)).toBe(true); - for (let index = 1; index < frames.length; index += 1) { - expect(frames[index].scrollTop, "rejected near-bottom collapse cannot clamp scrollTop").toBeCloseTo( - frames[0].scrollTop, - 0, + await filterTrigger.click(); + const filterSheet = page.getByTestId("calculators-filter-sheet"); + await expect(filterSheet).toBeVisible(); + const filterGeometry = await filterSheet.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + right: element.getBoundingClientRect().right, + })); + expect(filterGeometry.scrollWidth, `${width}px calculator filters must not overflow`).toBeLessThanOrEqual( + filterGeometry.clientWidth + 1, ); - expect(frames[index].maxOffset, "rejected near-bottom collapse keeps the scroll range stable").toBeCloseTo( - frames[0].maxOffset, - 0, + expect(filterGeometry.right, `${width}px calculator filters stay inside the viewport`).toBeLessThanOrEqual( + width + 1, ); + await page.keyboard.press("Escape"); + await expect(filterSheet).toBeHidden(); + await expect(filterTrigger).toBeFocused(); } -}); -test("calculator reserve and dock hide and reveal monotonically with sufficient near-bottom runway", async ({ - page, -}) => { - await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.setViewportSize(phoneViewport); - await gotoPhoneSurface(page, "/calculators", 112); - await addPhoneScrollRunway(page); - - const frames = await page.evaluate(async () => { - const main = document.getElementById("main-content"); - const dock = document.querySelector('[data-testid="calculators-phone-dock"]'); - const reserve = document.querySelector('[data-testid="calculators-search-page"]'); - if (!main || !dock || !reserve) throw new Error("calculator reserve geometry was not rendered"); - const mainOwnsScroll = - /^(?:auto|scroll|overlay)$/.test(getComputedStyle(main).overflowY) && main.scrollHeight > main.clientHeight + 1; - const scrollOwner = mainOwnsScroll ? main : (document.scrollingElement ?? document.documentElement); - const read = () => ({ - hidden: dock.getAttribute("data-scroll-hidden") === "true", - dockTop: dock.getBoundingClientRect().top, - paddingBottom: Number.parseFloat(getComputedStyle(reserve).paddingBottom), - scrollTop: scrollOwner.scrollTop, - maxOffset: scrollOwner.scrollHeight - scrollOwner.clientHeight, - scrollAnchor: getComputedStyle(scrollOwner).overflowAnchor, - transitionDuration: getComputedStyle(reserve).transitionDuration, - }); - const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); - const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); - const collapseBudget = - (collapse?.getBoundingClientRect().height ?? 0) + - (safeArea?.getBoundingClientRect().height ?? 0) + - Number.parseFloat(getComputedStyle(reserve).paddingBottom); - const targetOffset = Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight - collapseBudget - 256); - scrollOwner.scrollTop = Math.max(0, targetOffset - 24); - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - scrollOwner.scrollTop = targetOffset; - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - const hiding = [read()]; - for (let index = 0; index < 18; index += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - hiding.push(read()); - } - scrollOwner.scrollTop -= 12; - (mainOwnsScroll ? main : window).dispatchEvent(new Event("scroll", { bubbles: true })); - const reveal = [read()]; - for (let index = 0; index < 16; index += 1) { - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - reveal.push(read()); - } - return { hiding, reveal }; - }); - - const firstHidden = frames.hiding.findIndex((frame) => frame.hidden); - expect(firstHidden, "sufficient-runway downward intent hides the calculator dock").toBeGreaterThan(-1); - const hiding = frames.hiding.slice(firstHidden); - expect(hiding.some((frame) => frame.transitionDuration.includes("0.24s"))).toBe(true); - expect( - hiding.some((frame) => frame.scrollAnchor === "none"), - "page-owned reserve transition disables anchoring", - ).toBe(true); - for (let index = 1; index < hiding.length; index += 1) { - expect(hiding[index].paddingBottom, "calculator reserve never reverses during hide").toBeLessThanOrEqual( - hiding[index - 1].paddingBottom + 1, - ); - expect(hiding[index].dockTop, "dock never reverses during hide").toBeGreaterThanOrEqual( - hiding[index - 1].dockTop - 1, - ); - expect( - hiding[index].scrollTop, - `scroll remains monotonic during hide at frame ${index}: ${JSON.stringify({ previous: hiding[index - 1], current: hiding[index] })}`, - ).toBeGreaterThanOrEqual(hiding[index - 1].scrollTop - 1); - } - const firstRevealed = frames.reveal.findIndex((frame) => !frame.hidden); - expect(firstRevealed, "upward intent reveals the calculator dock").toBeGreaterThan(-1); - const revealing = frames.reveal.slice(firstRevealed); - for (let index = 1; index < revealing.length; index += 1) { - expect(revealing[index].paddingBottom, "calculator reserve never reverses during reveal").toBeGreaterThanOrEqual( - revealing[index - 1].paddingBottom - 1, - ); - expect(revealing[index].dockTop, "dock never reverses during reveal").toBeLessThanOrEqual( - revealing[index - 1].dockTop + 1, - ); - expect(revealing[index].scrollTop, "scroll remains monotonic during reveal").toBeLessThanOrEqual( - revealing[index - 1].scrollTop + 1, - ); + await page.setViewportSize({ width: 390, height: 844 }); + for (const media of [ + { colorScheme: "dark" as const, forcedColors: "none" as const, reducedMotion: "no-preference" as const }, + { colorScheme: "light" as const, forcedColors: "none" as const, reducedMotion: "reduce" as const }, + { colorScheme: "light" as const, forcedColors: "active" as const, reducedMotion: "no-preference" as const }, + ]) { + await page.emulateMedia(media); + await gotoPhoneSurface(page, "/calculators?q=depression&run=1", 112); + await expect(page.getByTestId("calculators-search-page")).toBeVisible(); + await expect(page.getByTestId("calculators-filter-trigger-phone")).toBeVisible(); } }); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 2d40d2379..6ee42b12b 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -146,6 +146,21 @@ async function openComposer(page: Page, href = "/?mode=documents&focus=1") { test.beforeEach(stubZeroTouchPoints); test.describe("universal search typeahead", () => { + test("keeps calculator command suggestions local", async ({ page }) => { + let universalRequestCount = 0; + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + universalRequestCount += 1; + await route.fulfill({ status: 204 }); + }); + + const input = await openComposer(page, "/calculators?focus=1"); + await input.fill("depression"); + await expect(page.getByRole("option", { name: /depression severity.*PHQ-9/i })).toBeVisible(); + await page.waitForTimeout(500); + + expect(universalRequestCount).toBe(0); + }); + test("shows grouped cross-entity results while typing", async ({ page }) => { await mockUniversalSearch(page); const input = await openComposer(page);