From 9b34a0149759b2c2f1e8ed5d37f02ba1ac35cf39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 03:25:55 +0000 Subject: [PATCH 1/4] Therapy: build the compare set from where the therapies are, on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing therapies to compare on a phone was the wrong shape twice over. `/therapy-compass/compare` drew four empty slots before anything was chosen, and once two were chosen the comparison was a `min-w-[720px]` table inside a horizontal scroller: on a 390px phone that is two thirds of a column at a time, with the field labels scrolling away from the values they label. Four directions were prototyped as design scratch (PR #2339); this builds the chosen one for real. The model is that you fill the compare set *where the therapies are* — from a list, a search result, or while reading a record — and the comparison screen is only ever reached with something already in it. Adding no longer navigates. `ResultCard` moves from `toggleCompare` to `addCompare`/`removeCompare`, and the label moves with the behaviour: "Compare" promised a destination it no longer goes to, so it reads "Add to compare" / "In compare tray". `toggleCompare` is removed from the bindings API rather than left as a dead navigate-on-add path, and the record nav rail's Compare slot becomes pure navigation in both branches — one control with two different meanings, depending on state the reader could not see, was the problem it existed to demonstrate. The tray docks above the phone search pill through the documented addon slot, so it inherits the dock's fixed position, z-index, safe-area padding and scroll-hide transform: no second scroll listener and no bottom-offset arithmetic. Two invariants hold it there. It stays exactly one row tall — the dock's content clearance is a static token, so a dock that grows covers page content by exactly its own growth, which is why the expanded state is a bottom Sheet rather than the taller bar the prototype drew. And the shell claims the slot only while the URL carries a compare set, because the reserve inflates on claim rather than on render, so a claim with an empty tray would open a blank band under a row that is not there. Record routes have no search pill, so they get an "Add to compare" button in the page body instead — page content, not chrome, introducing no second bottom owner. It announces through `role="status"`: with no tray on screen, an add there would otherwise be completely silent. The comparison forks at `md`, not `sm`. The table is 720px wide, so at 640–767px it would still scroll sideways — the exact defect being fixed. Below that width the same `rows` memo is turned inside out: one card per field, every therapy listed against it, so the label never leaves the value. Device memory is additive and the URL stays the source of truth. A shared `?ids=` link always wins; memory only fills the gap when you arrive with no `ids` at all. Remembered slugs are validated against the live catalogue at restore time, so one from an older data generation is dropped rather than resurrected as a therapy that no longer exists. `data-therapy-scroll-sm` and its globals.css rule are removed together: phones no longer render that table, so the rule could not match anything and the contract assertion on it would have passed for the wrong reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WKxKU17WQqryM8iNhpyJk7 --- docs/design-system/COMPONENTS.md | 4 +- docs/design-system/adoption-manifest.json | 6 + docs/search-chrome-behaviour.md | 29 ++- src/app/globals.css | 129 ++++++++- .../global-search-shell.tsx | 25 +- .../mobile-composer-reserve.ts | 19 ++ src/components/therapy-compass/bindings.tsx | 56 +++- .../therapy-compass/record/compare-action.tsx | 94 +++++++ .../therapy-compass/screens/brief-screen.tsx | 5 + .../screens/compare-screen.tsx | 82 +++++- .../therapy-compass/screens/detail-screen.tsx | 2 + .../therapy-compass/screens/sheets-screen.tsx | 5 + .../therapy-compass/therapy-card.tsx | 32 ++- .../therapy-compass/therapy-compare-tray.tsx | 245 ++++++++++++++++++ .../therapy-record-nav-header.tsx | 9 +- src/components/therapy-compass/workspace.tsx | 5 + src/lib/mode-home-composer.ts | 11 +- src/lib/therapy-compare-memory.ts | 90 +++++++ src/lib/therapy-compass-navigation.ts | 34 +++ tests/helpers/style-contracts.ts | 14 + tests/phone-dock-addon-contract.test.ts | 66 ++++- tests/recommend-screen.dom.test.tsx | 4 +- .../therapy-compare-add-in-place.dom.test.tsx | 136 ++++++++++ tests/therapy-compare-memory.dom.test.tsx | 166 ++++++++++++ tests/therapy-compare-memory.test.ts | 112 ++++++++ .../therapy-compare-phone-layout.dom.test.tsx | 147 +++++++++++ tests/therapy-compare-tray.dom.test.tsx | 165 ++++++++++++ .../therapy-compass-compare-url.dom.test.tsx | 15 +- ...herapy-compass-responsive-contract.test.ts | 20 +- ...herapy-compass-search-filters.dom.test.tsx | 4 +- tests/therapy-favourite.dom.test.tsx | 4 +- tests/therapy-record-nav.dom.test.tsx | 20 +- tests/ui-route-coverage.spec.ts | 17 +- tests/ui-therapy-nav-scroll.spec.ts | 72 +++++ 34 files changed, 1770 insertions(+), 74 deletions(-) create mode 100644 src/components/therapy-compass/record/compare-action.tsx create mode 100644 src/components/therapy-compass/therapy-compare-tray.tsx create mode 100644 src/lib/therapy-compare-memory.ts create mode 100644 tests/therapy-compare-add-in-place.dom.test.tsx create mode 100644 tests/therapy-compare-memory.dom.test.tsx create mode 100644 tests/therapy-compare-memory.test.ts create mode 100644 tests/therapy-compare-phone-layout.dom.test.tsx create mode 100644 tests/therapy-compare-tray.dom.test.tsx diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 78ff4a48f2..ccb12bfa62 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -974,7 +974,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `AnswerFooter` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `Button` | controls | yes | yes | inherited-global-root | yes | no | 14 | +| `Button` | controls | yes | yes | inherited-global-root | yes | no | 16 | | `Checkbox` | controls | yes | yes | no | yes | no | 0 | | `Chip` | controls | yes | yes | inherited-global-root | yes | no | 5 | | `ChoiceChip` | controls | yes | yes | inherited-global-root | yes | no | 4 | @@ -1010,7 +1010,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 29 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 30 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index cca0213fdd..c4a91631fa 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -281,6 +281,7 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/privacy-quiet-signal-page.tsx", + "src/components/therapy-compass/record/compare-action.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", "src/components/therapy-compass/screens/other-screen.tsx", @@ -288,6 +289,7 @@ "src/components/therapy-compass/screens/recommend-screen.tsx", "src/components/therapy-compass/screens/search-screen.tsx", "src/components/therapy-compass/therapy-card.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/therapy-compass/workspace.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", @@ -297,6 +299,7 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/privacy-quiet-signal-page.tsx", + "src/components/therapy-compass/record/compare-action.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", "src/components/therapy-compass/screens/other-screen.tsx", @@ -304,6 +307,7 @@ "src/components/therapy-compass/screens/recommend-screen.tsx", "src/components/therapy-compass/screens/search-screen.tsx", "src/components/therapy-compass/therapy-card.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/therapy-compass/workspace.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", @@ -1660,6 +1664,7 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/therapy-compass/record/key-facts.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx", "src/components/ward-management/ward-management-navigation.tsx" @@ -1692,6 +1697,7 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/therapy-compass/record/key-facts.tsx", + "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx" ], diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index ed460c667c..ab0f5b3cef 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -764,12 +764,13 @@ floating element: it portals into a slot rendered _inside_ the dock's `
` z-index, safe-area padding and scroll-hide transform. There is no bottom-offset arithmetic and no second scroll listener anywhere in an addon. -Two claimants exist, and they are mutually exclusive by surface: +Three claimants exist, and they are mutually exclusive by surface: -| Addon kind | Slot id | Claimed by | -| ----------------------- | ----------------------------------------- | ----------------------------------------------------------- | -| `differentials-compare` | `differentials-mobile-compare-addon-slot` | Differentials submitted search / `/differentials/diagnoses` | -| `patient-details` | `patient-details-addon-slot` | Prescribing submitted search (dashboard-owned) | +| Addon kind | Slot id | Claimed by | +| ----------------------- | ----------------------------------------- | ----------------------------------------------------------------------------- | +| `differentials-compare` | `differentials-mobile-compare-addon-slot` | Differentials submitted search / `/differentials/diagnoses` | +| `patient-details` | `patient-details-addon-slot` | Prescribing submitted search (dashboard-owned) | +| `therapy-compare` | `therapy-compare-addon-slot` | Therapy Compass dock routes, **and only while the URL carries a compare set** | Rules: @@ -788,13 +789,27 @@ Rules: opens a blank band at the bottom. `/medications` is a standalone mode home with the composer in the hero and no dock at all; `/medications/[slug]` already opens the patient sheet from its own nav header, so neither claims the addon. +- **An addon that can be empty must gate its claim on being non-empty.** The + therapy compare tray renders nothing until something is in the comparison, so + claiming the slot on every therapy route would reserve a tray-sized band under + a row that is not there. The shell therefore reads the set out of the URL + (`readTherapyCompareSlugCount`) and claims only when it is non-empty — the + claim and the render have to agree, in both directions. +- **Keep an addon exactly one row tall.** The clearance is a static token, so a + dock that grows covers page content by exactly its own growth. Anything that + needs more room opens a bottom `Sheet` instead of a taller bar; that is why + the compare tray's expanded state is a sheet and the Patient details panel is + one too. - **Gate the portal at 639px**, matching `.phone-footer-layer`'s `sm:fixed`. The two Compare bars gate at 1023px, which between 640–1023px portals into a slot on a form that is not fixed. Do not copy that. Coverage: `tests/phone-dock-addon-contract.test.ts` (registry, exclusivity, CSS/TS -value parity), `tests/patient-details-dock-action.dom.test.tsx` (portal target, -breakpoint, sheet wiring). +value parity, therapy route/claim gating), `tests/patient-details-dock-action.dom.test.tsx` +(portal target, breakpoint, sheet wiring), `tests/therapy-compare-tray.dom.test.tsx` +(portal target, breakpoint, empty-set silence, sheet wiring), +`tests/ui-therapy-nav-scroll.spec.ts` (the tray hides with the composer and +releases its reserve to `0rem`). ## Motion & Animation Preferences (#S4K1GA) diff --git a/src/app/globals.css b/src/app/globals.css index a6960ea81f..a77bba4225 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -584,10 +584,20 @@ clears roughly 3.5rem less than the Compare bar. */ --phone-dock-patient-details-clearance: 9rem; --phone-dock-patient-details-compact-clearance: 8.75rem; + /* Therapy compare tray: one row + 0.5rem gap, same geometry as Patient + details. The tray is required to stay exactly one row tall (its expanded + state is a sheet), which is what lets a single static number stay correct. */ + --phone-dock-therapy-compare-clearance: 9rem; + --phone-dock-therapy-compare-compact-clearance: 8.75rem; --patient-details-fab-gap: 0.5rem; --patient-details-fab-padding: 0.3125rem 0.5rem 0.3125rem 1rem; --patient-details-fab-count-size: 1.625rem; --patient-details-fab-count-padding: 0.375rem; + --therapy-compare-tray-gap: 0.5rem; + --therapy-compare-tray-pip-gap: 0.125rem; + --therapy-compare-tray-pip-size: 1.5rem; + --therapy-compare-tray-padding: 0.25rem 0.5rem; + --therapy-compare-tray-summary-inset: 0.25rem; /* Radius tokens are the single source of truth in @theme above (they also generate the rounded-* utilities); do not redefine them here or var() and the utilities drift apart. */ @@ -1459,6 +1469,101 @@ summary::-webkit-details-marker { font-weight: 800; } +/* Therapy Compass compare tray: the row above the search pill that says what is + in the comparison. Same in-flow trick as the two rows above, so scroll-hide + carries it with the composer. + + It must stay ONE row tall at every state — the dock's content clearance is a + static token, so a dock that grows covers page content by exactly its growth. + The expanded state is a bottom sheet for that reason, not a taller bar. + + Interaction states are NOT redeclared here: the summary row carries + `interactiveRowBase` and the actions are the shared `Button`, so hover, focus + and disabled behaviour come from the design system, per COMPONENTS.md 9.1. */ +.therapy-compare-tray { + position: relative; + z-index: var(--z-raised, 10); + display: flex; + width: 100%; + align-items: center; + gap: var(--therapy-compare-tray-gap); + margin-bottom: var(--therapy-compare-tray-gap); + border-radius: var(--radius-lg); + border: 1px solid var(--border-strong); + background: color-mix(in srgb, var(--surface) 94%, transparent); + padding: var(--therapy-compare-tray-padding); + box-shadow: var(--e3); +} + +.therapy-compare-tray__summary { + min-width: 0; + flex: 1 1 auto; + gap: var(--therapy-compare-tray-gap); + border-radius: var(--radius-md); + padding-inline: var(--therapy-compare-tray-summary-inset); +} + +.therapy-compare-tray__pips { + display: flex; + flex-shrink: 0; + gap: var(--therapy-compare-tray-pip-gap); +} + +.therapy-compare-tray__pip { + display: grid; + height: var(--therapy-compare-tray-pip-size); + width: var(--therapy-compare-tray-pip-size); + place-items: center; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-pill, 9999px); + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 900; + transition: transform var(--duration-moderate) var(--ease-out-soft); +} + +.therapy-compare-tray__pip[data-filled="true"] { + border-color: transparent; + background: var(--clinical-accent); + color: var(--clinical-accent-contrast); +} + +/* The arriving slot pulses once. Derived from the set, so it fires whichever + control added the therapy. */ +.therapy-compare-tray__pip[data-landed="true"] { + transform: scale(1.25); +} + +@media (prefers-reduced-motion: reduce) { + .therapy-compare-tray__pip { + transition: none; + } + + .therapy-compare-tray__pip[data-landed="true"] { + transform: none; + } +} + +.therapy-compare-tray__names { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-heading); + font-size: 0.8125rem; + font-weight: 800; +} + +.therapy-compare-tray__count { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 700; +} + /* Bottom-docked composer: hint row + upward dropdown grow above the pill. */ .answer-footer-search-edge { overflow: visible; @@ -2777,6 +2882,17 @@ summary::-webkit-details-marker { height: max(var(--phone-dock-patient-details-compact-clearance), calc(var(--safe-area-bottom) + 7.5rem)); } + /* Therapy Compass: the compare tray sits above the search pill in the dock. + Heights track --phone-dock-therapy-compare-* (same as content reserve). */ + .answer-footer-search-dock[data-footer-addon="therapy-compare"] .answer-footer-search-backdrop { + height: max(var(--phone-dock-therapy-compare-clearance), calc(var(--safe-area-bottom) + 7.75rem)); + } + + .answer-footer-search-dock.document-mobile-search-compact[data-footer-addon="therapy-compare"] + .answer-footer-search-backdrop { + height: max(var(--phone-dock-therapy-compare-compact-clearance), calc(var(--safe-area-bottom) + 7.5rem)); + } + .document-mobile-search-pill { min-height: 3.6rem; border-color: var(--border-strong); @@ -2867,6 +2983,13 @@ summary::-webkit-details-marker { transform: translateY(calc(100% + 0.5rem + var(--safe-area-bottom))); } + /* Therapy compare tray: shell dock (.document-mobile-search-edge), one row, + so the same 0.5rem overshoot as Patient details. Without it the tray's top + border can leave a subpixel peep at the viewport edge while hidden. */ + .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-footer-addon="therapy-compare"][data-scroll-hidden="true"] { + transform: translateY(calc(100% + 0.5rem + var(--safe-area-bottom))); + } + /* Document viewer owns its floating composer (not the shared dock). Lift it with the same --keyboard-height signal so overlay/visual keyboards do not cover the focused input on document detail/source routes. */ @@ -4823,12 +4946,6 @@ html.theme-transitioning *:after { } @media (max-width: 640px) { - [data-therapy-scroll-sm] { - overflow-x: auto !important; - overscroll-behavior-inline: contain; - -webkit-overflow-scrolling: touch; - scrollbar-width: thin; - } .therapy-pathway-list { border-right: 0 !important; border-bottom: 1px solid var(--border); diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 25ebe75b25..7a92dc9425 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -105,10 +105,12 @@ import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal- import { desktopPageComposerSlotId, differentialsMobileCompareAddonSlotId, + therapyCompareAddonSlotId, modeHomeComposerReservePendingValue, modeHomeDesktopComposerSlotId, } from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; +import { isTherapyPhoneDockRoute, readTherapyCompareSlugCount } from "@/lib/therapy-compass-navigation"; import { isAlwaysStandaloneShellPath, isDashboardOwnedModeHomePath, @@ -448,6 +450,14 @@ function GlobalStandaloneSearchShellBody({ // `/differentials` is absent on purpose: it redirects to the shared home, so a // branch naming it can never be true and would only read as live ownership. (pathname === "/differentials/diagnoses" || pathname === "/differentials/search"); + // The therapy compare tray docks above the phone search pill. The claim is + // gated on the URL actually carrying a set, not merely on being on a therapy + // route: the dock reserve inflates on CLAIM, so claiming with an empty tray + // would open a blank band under a row that renders nothing. + const therapyCompareAddonActive = + searchMode === "therapy-compass" && + isTherapyPhoneDockRoute(pathname) && + readTherapyCompareSlugCount(searchParams) > 0; const clinicalAskMode = isClinicalAskModeId(searchMode) ? searchMode : null; // No shell-owned route claims the Patient details dock addon. `/medications` // is a standalone mode home (composer in the hero, no dock to portal into), @@ -509,6 +519,7 @@ function GlobalStandaloneSearchShellBody({ heroOwnsPhoneComposer, searchMode, differentialsCompareAddonActive, + therapyCompareAddonActive, }), ); @@ -935,9 +946,19 @@ function GlobalStandaloneSearchShellBody({ // maximum screen space (mode homes and result views alike). mobileBottomSearchVariant="compact" mobileBottomSearchAddonSlotId={ - differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined + differentialsCompareAddonActive + ? differentialsMobileCompareAddonSlotId + : therapyCompareAddonActive + ? therapyCompareAddonSlotId + : undefined + } + mobileBottomSearchAddonKind={ + differentialsCompareAddonActive + ? "differentials-compare" + : therapyCompareAddonActive + ? "therapy-compare" + : undefined } - mobileBottomSearchAddonKind={differentialsCompareAddonActive ? "differentials-compare" : undefined} desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} showPhoneSuggestionTickerOnHome={isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)} searchComposerVisible={shouldShowSearchComposer} diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index 8c1bb6aa2e..04185227a5 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -34,6 +34,18 @@ export const mobileComposerDifferentialsCompareReserve = */ export const mobileComposerPatientDetailsReserve = "calc(9rem + var(--safe-area-bottom) + var(--keyboard-height, 0px))"; +/** + * Therapy Compass: compare tray + compact search pill. + * + * Same geometry as the Patient details pill — one ~3rem row plus the 0.5rem gap + * above the composer — because the tray is required to stay exactly one row + * tall. Its expanded state is a sheet, not a taller dock, precisely so this + * single static number can keep being correct. Keep the rem figure equal to + * --phone-dock-therapy-compare-clearance in globals.css; + * tests/mobile-composer-reserve.test.ts pins the pair. + */ +export const mobileComposerTherapyCompareReserve = "calc(9rem + var(--safe-area-bottom) + var(--keyboard-height, 0px))"; + // Every phone dock is the compact single-row pill (mode homes and result views // alike); only the answer dock with a follow-up chip row is taller. The answer // values are derived from the dock constants so the pairs cannot silently @@ -49,6 +61,7 @@ export const mobileComposerVisibleReserve = { dashboardDock: dashboardCompactSingleRowReserve, differentialsCompare: mobileComposerDifferentialsCompareReserve, patientDetails: mobileComposerPatientDetailsReserve, + therapyCompare: mobileComposerTherapyCompareReserve, } as const; export function resolveMobileComposerReserve(bottomComposerHidden: boolean, visibleReserve: string): string { @@ -85,6 +98,7 @@ export function resolveDashboardVisibleMobileComposerReserve(input: { hasAnswerFollowUps: boolean; differentialsCompareAddonActive: boolean; patientDetailsAddonActive?: boolean; + therapyCompareAddonActive?: boolean; /** Hero owns the phone composer (no fixed bottom dock) — match shell idle pad. */ heroOwnsPhoneComposer?: boolean; }): string { @@ -105,6 +119,9 @@ export function resolveDashboardVisibleMobileComposerReserve(input: { if (input.patientDetailsAddonActive) { return mobileComposerVisibleReserve.patientDetails; } + if (input.therapyCompareAddonActive) { + return mobileComposerVisibleReserve.therapyCompare; + } return mobileComposerVisibleReserve.dashboardDock; } @@ -118,6 +135,7 @@ export function resolveShellVisibleMobileComposerReserve(input: { searchMode: string; differentialsCompareAddonActive: boolean; patientDetailsAddonActive?: boolean; + therapyCompareAddonActive?: boolean; }): string { if (!input.shouldShowSearchComposer) { // Page-owned composers (DocumentViewer) manage their own dock @@ -133,5 +151,6 @@ export function resolveShellVisibleMobileComposerReserve(input: { if (input.searchMode === "answer") return mobileComposerVisibleReserve.shellAnswer; if (input.differentialsCompareAddonActive) return mobileComposerVisibleReserve.differentialsCompare; if (input.patientDetailsAddonActive) return mobileComposerVisibleReserve.patientDetails; + if (input.therapyCompareAddonActive) return mobileComposerVisibleReserve.therapyCompare; return mobileComposerVisibleReserve.shellDock; } diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index 51eee12e22..0ee981060e 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -1,6 +1,15 @@ "use client"; -import { createContext, useContext, useEffect, useMemo, useState, useDeferredValue, type ReactNode } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + useDeferredValue, + type ReactNode, +} from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { @@ -15,6 +24,7 @@ import { type TherapySheetSection, type TherapyWorkspaceState, } from "@/lib/therapy-compass-navigation"; +import { readTherapyCompareMemory, writeTherapyCompareMemory } from "@/lib/therapy-compare-memory"; import { useTherapyData } from "./data/use-therapy-data"; import { THERAPY_CATALOGUE_SUMMARY } from "./data/generated-assets"; @@ -90,7 +100,6 @@ export type TcBindings = { // ---- compare -------------------------------------------------------- compareSlugs: string[]; compareTherapies: Therapy[]; - toggleCompare: (slug: string) => void; // add/remove + navigate addCompare: (slug: string) => void; replaceCompareSlugs: (slugs: readonly string[]) => void; removeCompare: (slug: string) => void; @@ -485,15 +494,6 @@ export function TcProvider({ children }: { children: ReactNode }) { compareSlugs, compareTherapies, - toggleCompare: (slug) => { - const next = compareSlugs.includes(slug) - ? compareSlugs.filter((value) => value !== slug) - : compareSlugs.length >= THERAPY_MAX_COMPARE - ? compareSlugs - : [...compareSlugs, slug]; - setCompareSlugs(next); - pushWorkspace(therapyScreenHref("compare"), { compareSlugs: next }); - }, addCompare: (slug) => { const next = compareSlugs.includes(slug) || compareSlugs.length >= THERAPY_MAX_COMPARE @@ -678,6 +678,40 @@ export function TcProvider({ children }: { children: ReactNode }) { sheetClinician, ]); + // ---- Device memory for the compare set ------------------------------- + // + // The URL stays the source of truth. Memory only fills the gap when you + // arrive with no `ids` at all, so an interrupted comparison is not lost. Both + // effects run after mount and never during render, so server HTML, the first + // client render and hydration stay identical whatever is in storage. + // + // "Decided" is a ref, not state, and that ordering is load-bearing: effects in + // one commit run in declaration order, so the restore below always resolves + // before the mirror reads it, without a second render to carry a flag. + const compareRestoreDecided = useRef(false); + + useEffect(() => { + if (compareRestoreDecided.current) return; + // A shared link always wins, and can be decided without the catalogue. + if (workspaceFromUrl.compareSlugs.length > 0) { + compareRestoreDecided.current = true; + return; + } + // Restoring needs the catalogue: a slug from an older data generation must + // be dropped rather than resurrected as a therapy that no longer exists. + if (therapies.length === 0) return; + compareRestoreDecided.current = true; + const remembered = readTherapyCompareMemory().filter((slug) => bySlug.has(slug)); + if (remembered.length > 0) value.replaceCompareSlugs(remembered); + }, [workspaceFromUrl.compareSlugs.length, therapies.length, bySlug, value]); + + useEffect(() => { + // Writing before the restore has decided would clobber the remembered set + // with the empty one this tab started from. + if (!compareRestoreDecided.current) return; + writeTherapyCompareMemory(compareSlugs); + }, [compareSlugs]); + return {children}; } diff --git a/src/components/therapy-compass/record/compare-action.tsx b/src/components/therapy-compass/record/compare-action.tsx new file mode 100644 index 0000000000..5fcb1cbbff --- /dev/null +++ b/src/components/therapy-compass/record/compare-action.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useId, useState } from "react"; +import { Scale } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ignoreUnavailableActivation } from "@/components/ui-primitives"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +import { useTcBindings } from "../bindings"; +import type { Therapy } from "../data/types"; + +/** + * "Add to compare" on a therapy record. + * + * This lives in the page body rather than in the chrome, and that is deliberate. + * The compare tray docks above the phone search pill, and record pages have no + * pill — claiming a dock slot there would open a blank band at the bottom of the + * page (`docs/search-chrome-behaviour.md`, "reserve inflates on claim"). So the + * record keeps the design's headline move — add it while you are reading it — + * as ordinary page content, and introduces no second bottom owner. + * + * The status line is not decoration. On these routes the tray is not on screen, + * so without it an add is completely silent: the reader taps, nothing visible + * changes, and the only feedback is a URL they are not looking at. + */ +export function TherapyCompareAction({ therapy }: { therapy: Therapy }) { + const b = useTcBindings(); + const fullNoteId = useId(); + const [notice, setNotice] = useState(null); + + const inCompare = b.isInCompare(therapy.slug); + const count = b.compareSlugs.length; + const full = !inCompare && count >= THERAPY_MAX_COMPARE; + + function announce(next: number, verb: string) { + setNotice(`${verb} — ${next} of ${THERAPY_MAX_COMPARE} selected to compare.`); + } + + return ( +
+
+ + {count >= 2 ? ( + + ) : null} +
+ {full ? ( + + The comparison already holds {THERAPY_MAX_COMPARE} therapies. Remove one before adding another. + + ) : null} +

+ {notice} +

+
+ ); +} diff --git a/src/components/therapy-compass/screens/brief-screen.tsx b/src/components/therapy-compass/screens/brief-screen.tsx index a2e870178e..04d5e3cc0b 100644 --- a/src/components/therapy-compass/screens/brief-screen.tsx +++ b/src/components/therapy-compass/screens/brief-screen.tsx @@ -17,6 +17,7 @@ import { InteractiveRow } from "@/components/ui/interactive-row"; import { parseSteps, summarise } from "../data/select"; import { LoadingState } from "../ui"; import { useClipboard } from "../use-clipboard"; +import { TherapyCompareAction } from "../record/compare-action"; import { TherapySaveNotice } from "../record/save-notice"; import { useTherapyFavourite } from "../use-therapy-favourite"; import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; @@ -110,6 +111,10 @@ export function BriefScreen() { } /> +
+ +
+ - {items.length} of 4 selected + {items.length} of {THERAPY_MAX_COMPARE} selected } actions={ @@ -208,13 +208,13 @@ export function CompareScreen() { { id: "all", label: "All fields" }, ]} > - {/* table */} + {/* Phones read the same rows stacked; see TherapyCompareStack below. */}
@@ -283,6 +283,7 @@ export function CompareScreen() {
Therapy comparison by clinical field
+
+ b.open(slug)} /> diff --git a/src/components/therapy-compass/screens/sheets-screen.tsx b/src/components/therapy-compass/screens/sheets-screen.tsx index 458b111be5..82de055b06 100644 --- a/src/components/therapy-compass/screens/sheets-screen.tsx +++ b/src/components/therapy-compass/screens/sheets-screen.tsx @@ -17,6 +17,7 @@ import { parseSteps, searchTherapies } from "../data/select"; import { LoadingState } from "../ui"; import { InteractiveRow, interactiveRowBase } from "@/components/ui/interactive-row"; import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; +import { TherapyCompareAction } from "../record/compare-action"; import { TherapySaveNotice } from "../record/save-notice"; import { useTherapyFavourite } from "../use-therapy-favourite"; @@ -63,6 +64,10 @@ export function SheetsScreen() { /> +
+ +
+
{/* BUILDER */}
diff --git a/src/components/therapy-compass/therapy-card.tsx b/src/components/therapy-compass/therapy-card.tsx index e53b2b4628..ddc228d695 100644 --- a/src/components/therapy-compass/therapy-card.tsx +++ b/src/components/therapy-compass/therapy-card.tsx @@ -17,6 +17,7 @@ import { import { cardSurface } from "@/components/card-recipes"; import { Button } from "@/components/ui/button"; import { cn, ignoreUnavailableActivation } from "@/components/ui-primitives"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; import { useTcBindings } from "./bindings"; import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select"; @@ -51,6 +52,9 @@ export function ResultCard({ const sheetUnavailableId = useId(); const { notice, saved, toggleFavourite } = useTherapyFavourite(therapy.slug); const inCompare = b.isInCompare(therapy.slug); + // A full tray keeps its tab stop and states why, per the wiring convention. + const compareFull = !inCompare && b.compareSlugs.length >= THERAPY_MAX_COMPARE; + const compareFullId = `${sheetUnavailableId}-compare-full`; const rankingQuery = query ?? b.search.query; const subtitle = cardPreviewText(therapy.clinicalSummary, { exclude: therapy.name }) || @@ -181,13 +185,33 @@ export function ResultCard({ block className={cn(cardActionButton, controlPressed)} icon={Scale} - onClick={() => b.toggleCompare(therapy.slug)} + // Adding no longer navigates: the tray above the composer is where the + // set is now assembled, so this control fills it and leaves the reader + // exactly where they were. The label moved with the behaviour — the + // old "Compare" promised a destination it no longer goes to. + onClick={ + compareFull + ? ignoreUnavailableActivation + : inCompare + ? () => b.removeCompare(therapy.slug) + : () => b.addCompare(therapy.slug) + } aria-pressed={inCompare} - aria-label={inCompare ? "In compare" : "Compare"} + aria-disabled={compareFull ? true : undefined} + aria-describedby={compareFull ? compareFullId : undefined} + title={compareFull ? `Compare holds ${THERAPY_MAX_COMPARE} therapies — remove one first` : undefined} + aria-label={inCompare ? "In compare tray" : compareFull ? "Compare tray full" : "Add to compare"} > - {inCompare ? "In compare" : "Compare"} - {inCompare ? "Added" : "Compare"} + + {inCompare ? "In compare tray" : compareFull ? "Tray full" : "Add to compare"} + + {inCompare ? "In tray" : compareFull ? "Full" : "Add"} + {compareFull ? ( + + The comparison already holds {THERAPY_MAX_COMPARE} therapies. Remove one before adding another. + + ) : null} + +
, + host, + )} + setSheetOpen(false)} + title="Compare tray" + description={`${count} of ${THERAPY_MAX_COMPARE} therapies selected.`} + mobilePlacement="bottom" + testId="therapy-compare-tray-sheet" + > +
    + {items.map((therapy, index) => ( +
  • + + + {therapy.name} + + +
  • + ))} +
+ {count === 1 ? ( +

+ Add one more to compare. Up to {THERAPY_MAX_COMPARE}. +

+ ) : null} + +
+ + ); +} + +/** + * Which slot just gained a therapy, or null. + * + * Derived from the set itself rather than reported by whatever added it, so the + * pip animates whichever control was used — a result card, a record button, a + * restored set — without any of them needing to know the tray exists. + */ +function useArrivalSlot(slugs: readonly string[]): number | null { + const previous = useRef(slugs); + const [landed, setLanded] = useState(null); + + useEffect(() => { + const grew = slugs.length > previous.current.length; + previous.current = slugs; + if (!grew) return; + setLanded(slugs.length - 1); + const timer = window.setTimeout(() => setLanded(null), 400); + return () => window.clearTimeout(timer); + }, [slugs]); + + return landed; +} + +/** + * The tray is one line wide, so it shows the record's own abbreviation where it + * has a short one ("CBT", "EMDR") and the full name otherwise. Truncation is + * left to CSS — cutting a therapy name mid-word in JS reads as a data error. + */ +function shortTherapyName(therapy: Therapy | undefined): string { + if (!therapy) return ""; + const alias = therapy.aliases.map((value) => value.trim()).find((value) => value.length > 0 && value.length <= 10); + return alias ?? therapy.name; +} diff --git a/src/components/therapy-compass/therapy-record-nav-header.tsx b/src/components/therapy-compass/therapy-record-nav-header.tsx index cfec0c0565..8ca050a732 100644 --- a/src/components/therapy-compass/therapy-record-nav-header.tsx +++ b/src/components/therapy-compass/therapy-record-nav-header.tsx @@ -102,10 +102,11 @@ export function TherapyRecordNavHeader({ if (destination === "overview") b.open(therapy.slug); else if (destination === "sheet") b.openSheet(therapy.slug); else if (destination === "brief") b.openBrief(therapy.slug); - // `toggleCompare` adds the therapy and navigates; once it is already in the - // set, toggling would remove it, so an established member just navigates. - else if (inCompare) b.goCompare(); - else b.toggleCompare(therapy.slug); + // Pure navigation, both branches. This slot used to add the therapy when it + // was not in the set and navigate when it was — one control with two + // different meanings depending on state the reader could not see. Adding + // now belongs to the record's own "Add to compare" button. + else b.goCompare(); } return ( diff --git a/src/components/therapy-compass/workspace.tsx b/src/components/therapy-compass/workspace.tsx index 9ec1b6e3c7..5714ed803d 100644 --- a/src/components/therapy-compass/workspace.tsx +++ b/src/components/therapy-compass/workspace.tsx @@ -11,6 +11,7 @@ import { cn, pageContainer } from "@/components/ui-primitives"; import { isInformationPage } from "@/lib/information-pages"; import { TcProvider, useTcBindings } from "./bindings"; +import { TherapyCompareTray } from "./therapy-compare-tray"; function TherapyCompassFooter() { return ( @@ -105,6 +106,10 @@ export function TherapyCompassWorkspace({ children }: { children: ReactNode }) { )}
+ {/* Mounted once for the whole mode. The tray decides for itself whether a + dock slot exists to portal into, so record routes (which have no phone + composer) simply get nothing. */} + ); } diff --git a/src/lib/mode-home-composer.ts b/src/lib/mode-home-composer.ts index f1080023b6..8dadaec034 100644 --- a/src/lib/mode-home-composer.ts +++ b/src/lib/mode-home-composer.ts @@ -33,20 +33,25 @@ export const differentialsMobileCompareAddonSlotId = "differentials-mobile-compa /** Phone search-composer slot for the medication surfaces' Patient details pill. */ export const patientDetailsAddonSlotId = "patient-details-addon-slot"; +/** Phone search-composer slot for the Therapy Compass compare tray. */ +export const therapyCompareAddonSlotId = "therapy-compare-addon-slot"; + /** * Which page-owned action currently occupies the phone dock's addon slot. * * The dock carries exactly ONE addon at a time — `data-footer-addon` is a single * attribute value, and the scrim height, hide transform, and content reserve are * all keyed off it. The two claimants are mutually exclusive by surface - * (differentials vs medications/prescribing) and `tests/phone-dock-addon-contract.test.ts` - * pins that, so the slot never has to hold two. + * (differentials vs medications/prescribing vs therapy) and + * `tests/phone-dock-addon-contract.test.ts` pins that, so the slot never has to + * hold two. */ -export type PhoneDockAddonKind = "differentials-compare" | "patient-details"; +export type PhoneDockAddonKind = "differentials-compare" | "patient-details" | "therapy-compare"; export const phoneDockAddonSlotId: Record = { "differentials-compare": differentialsMobileCompareAddonSlotId, "patient-details": patientDetailsAddonSlotId, + "therapy-compare": therapyCompareAddonSlotId, }; /** diff --git a/src/lib/therapy-compare-memory.ts b/src/lib/therapy-compare-memory.ts new file mode 100644 index 0000000000..5df64af403 --- /dev/null +++ b/src/lib/therapy-compare-memory.ts @@ -0,0 +1,90 @@ +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +/** + * Device memory for the Therapy compare set. + * + * The URL (`?ids=`) remains the source of truth — it is what a shared link + * carries and what `readTherapyWorkspaceState` validates. This module is a + * strictly additive convenience: when you arrive at Therapy with no `ids` in + * the URL, the set you last had is restored so an interrupted comparison is not + * lost. A URL that carries `ids` always wins, so a shared link can never be + * overwritten by whatever happens to be on the reader's device. + * + * Two deliberate non-goals: + * + * - **No cross-tab sync.** `search-pins` and `use-sidebar-pins` subscribe to + * the `storage` event because many components read them live. A compare set + * is bound to the address bar, so a remote write would rewrite *this* tab's + * URL underneath the reader mid-comparison. One consumer, no subscription. + * - **No catalogue validation here.** Storage is synchronous and the 205-record + * catalogue is fetched, so the caller drops unknown slugs at restore time + * when it actually has the records. This module owns shape only. + */ + +export const therapyCompareMemoryStorageKey = "clinical-kb-therapy-compare-v1"; + +// Browser storage can be unavailable (private mode, blocked site data) or full. +// The set is a convenience, so degrade to this tab's session rather than throw. +let inMemorySlugs: string[] | null = null; + +/** + * Shape-normalize a stored value: strings only, trimmed, no blanks, no + * duplicates, capped at the same ceiling the URL parser applies. Mirrors the + * rule `uniqueNonEmpty` applies in `therapy-compass-navigation.ts` so a value + * cannot survive here that the URL would reject. + */ +export function normalizeTherapyCompareSlugs(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const normalized: string[] = []; + for (const candidate of value) { + if (typeof candidate !== "string") continue; + const trimmed = candidate.trim(); + if (!trimmed || normalized.includes(trimmed)) continue; + normalized.push(trimmed); + if (normalized.length >= THERAPY_MAX_COMPARE) break; + } + return normalized; +} + +export function readTherapyCompareMemory(storage?: Pick): string[] { + let raw: string | null; + try { + const target = storage ?? (typeof window === "undefined" ? null : window.localStorage); + if (!target) return inMemorySlugs ? [...inMemorySlugs] : []; + raw = target.getItem(therapyCompareMemoryStorageKey); + } catch { + return inMemorySlugs ? [...inMemorySlugs] : []; + } + + if (!raw) return []; + try { + return normalizeTherapyCompareSlugs(JSON.parse(raw)); + } catch { + return []; + } +} + +/** + * Persist the set. An empty array is written, not skipped: an empty tray is a + * real preference, and skipping it would mean "Empty" is undone by a reload. + */ +export function writeTherapyCompareMemory(slugs: readonly string[], storage?: Pick): string[] { + const normalized = normalizeTherapyCompareSlugs([...slugs]); + try { + const target = storage ?? (typeof window === "undefined" ? null : window.localStorage); + if (!target) { + inMemorySlugs = [...normalized]; + return normalized; + } + target.setItem(therapyCompareMemoryStorageKey, JSON.stringify(normalized)); + inMemorySlugs = null; + } catch { + inMemorySlugs = [...normalized]; + } + return normalized; +} + +/** Test-only: drop tab session memory so cases do not leak sets into each other. */ +export function resetTherapyCompareMemoryForTests() { + inMemorySlugs = null; +} diff --git a/src/lib/therapy-compass-navigation.ts b/src/lib/therapy-compass-navigation.ts index ecd92544d9..2be1d81022 100644 --- a/src/lib/therapy-compass-navigation.ts +++ b/src/lib/therapy-compass-navigation.ts @@ -109,6 +109,40 @@ export function resolveTherapyRoute(pathname: string): { screen: string; slug: s return { screen, slug: safeDecodePathSegment(first) }; } +/** + * Therapy routes that render the shared phone bottom dock, and therefore have a + * slot the compare tray can dock into. + * + * `/therapy-compass/recommend` is absent because it hides the shell composer + * (it owns an in-flow clinical-situation composer instead), and the record + * routes are absent because `isInformationPage` hides the composer there. The + * reserve inflates when a route CLAIMS the addon slot, not when the tray + * renders, so claiming a route with no dock opens a blank band at the bottom of + * the page — this list is what stops that. + */ +export const THERAPY_PHONE_DOCK_ROUTES = [ + THERAPY_COMPASS_BASE, + `${THERAPY_COMPASS_BASE}/search`, + `${THERAPY_COMPASS_BASE}/compare`, + `${THERAPY_COMPASS_BASE}/pathways`, + `${THERAPY_COMPASS_BASE}/review`, +] as const; + +export function isTherapyPhoneDockRoute(pathname: string): boolean { + return (THERAPY_PHONE_DOCK_ROUTES as readonly string[]).includes(pathname); +} + +/** + * How many therapies the URL currently selects for comparison. + * + * The shell needs this — and only this — to decide whether the compare tray has + * anything to dock, without pulling the whole workspace parser onto every + * render of every route. + */ +export function readTherapyCompareSlugCount(params: Pick): number { + return uniqueNonEmpty((params.get("ids") ?? "").split(","), THERAPY_MAX_COMPARE).length; +} + export function therapyScreenHref(screen: string): string { return screen === "home" ? THERAPY_COMPASS_BASE : `${THERAPY_COMPASS_BASE}/${screen}`; } diff --git a/tests/helpers/style-contracts.ts b/tests/helpers/style-contracts.ts index 7338ce7acb..187ab6886d 100644 --- a/tests/helpers/style-contracts.ts +++ b/tests/helpers/style-contracts.ts @@ -405,6 +405,20 @@ export const STYLE_CONTRACT_EXEMPTIONS: Readonly> = { "patient-details-fab__button--active": "patient details dock pill, populated state — no effect contract yet (#094)", "patient-details-fab__count": "patient details count badge — no effect contract yet (#094)", "medication-mobile-result": "prescribing phone results — no effect contract yet (#094)", + + // Therapy compare tray. Phone-only (it portals into the dock's addon slot + // below 639px), so it is not reachable at the desktop viewport + // `ui-style-contract.spec.ts` drives — the same reason the Patient details + // pill above is exempt rather than contracted. What is covered: + // `tests/therapy-compare-tray.dom.test.tsx` pins the portal target, + // breakpoint, empty-set silence and sheet wiring, and + // `tests/ui-therapy-nav-scroll.spec.ts` proves in Chromium that the row hides + // with the composer and releases its reserve. None of that is a computed-style + // assertion, so these stay exemptions, not contracts. + "therapy-compare-tray": "therapy compare tray row — phone-only; no effect contract yet (#094)", + "therapy-compare-tray__pip": "therapy compare tray slot pip — phone-only; no effect contract yet (#094)", + "therapy-compare-tray__names": "therapy compare tray name line — phone-only; no effect contract yet (#094)", + "therapy-compare-tray__count": "therapy compare tray count line — phone-only; no effect contract yet (#094)", "search-band-count": "count weight/colour; the zero-result state needs a deterministic empty fixture first", "search-band-rule": "gradient divider — forced-colors fallback covered by ui-accessibility", diff --git a/tests/phone-dock-addon-contract.test.ts b/tests/phone-dock-addon-contract.test.ts index f776c0f43b..0adab75f3a 100644 --- a/tests/phone-dock-addon-contract.test.ts +++ b/tests/phone-dock-addon-contract.test.ts @@ -17,6 +17,7 @@ import { phoneDockAddonSlotId, differentialsMobileCompareAddonSlotId, patientDetailsAddonSlotId, + therapyCompareAddonSlotId, type PhoneDockAddonKind, } from "@/lib/mode-home-composer"; import { @@ -24,6 +25,11 @@ import { resolveDashboardVisibleMobileComposerReserve, resolveShellVisibleMobileComposerReserve, } from "@/components/clinical-dashboard/mobile-composer-reserve"; +import { + isTherapyPhoneDockRoute, + readTherapyCompareSlugCount, + THERAPY_MAX_COMPARE, +} from "@/lib/therapy-compass-navigation"; function read(relativePath: string): string { return readFileSync(path.resolve(process.cwd(), relativePath), "utf8"); @@ -34,12 +40,12 @@ const dashboard = read("src/components/ClinicalDashboard.tsx"); const shell = read("src/components/clinical-dashboard/global-search-shell.tsx"); const header = read("src/components/clinical-dashboard/master-search-header.tsx"); -const ADDON_KINDS: PhoneDockAddonKind[] = ["differentials-compare", "patient-details"]; +const ADDON_KINDS: PhoneDockAddonKind[] = ["differentials-compare", "patient-details", "therapy-compare"]; describe("phone dock addon registry", () => { it("maps every addon kind to a distinct slot id", () => { const ids = ADDON_KINDS.map((kind) => phoneDockAddonSlotId[kind]); - expect(ids).toEqual([differentialsMobileCompareAddonSlotId, patientDetailsAddonSlotId]); + expect(ids).toEqual([differentialsMobileCompareAddonSlotId, patientDetailsAddonSlotId, therapyCompareAddonSlotId]); expect(new Set(ids).size).toBe(ids.length); }); @@ -56,8 +62,9 @@ describe("addon kinds are mutually exclusive", () => { expect(dashboard).toMatch(/patientDetailsAddonActive\s*=\s*\n?\s*searchMode === "prescribing"/); }); - it("leaves the shell with a single claimant", () => { + it("keys the shell's two claimants on different modes", () => { expect(shell).toMatch(/differentialsCompareAddonActive\s*=\s*\n?\s*searchMode === "differentials"/); + expect(shell).toMatch(/therapyCompareAddonActive\s*=\s*\n?\s*searchMode === "therapy-compass"/); // No shell route claims the patient-details addon: `/medications` is a // standalone mode home with no dock, and `/medications/[slug]` already opens // the same sheet from its nav header. Claiming it here would inflate the @@ -91,6 +98,35 @@ describe("every addon kind carries its dock wiring", () => { }); }); +describe("the therapy tray only claims a dock that exists and has something in it", () => { + it("restricts the claim to therapy routes that render the phone dock", () => { + // `/therapy-compass/recommend` hides the shell composer and every record + // route is an information page, so neither has a slot to portal into. + // Claiming one would inflate the reserve for a row that never mounts. + expect(isTherapyPhoneDockRoute("/therapy-compass")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/search")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/compare")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/pathways")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/review")).toBe(true); + expect(isTherapyPhoneDockRoute("/therapy-compass/recommend")).toBe(false); + expect(isTherapyPhoneDockRoute("/therapy-compass/cognitive-behavioural-therapy-cbt")).toBe(false); + expect(isTherapyPhoneDockRoute("/therapy-compass/cognitive-behavioural-therapy-cbt/brief")).toBe(false); + }); + + it("requires the URL to carry a compare set before claiming the slot", () => { + // The reserve inflates on claim, not on render. An empty tray renders + // nothing, so a claim without `ids` is a blank band by construction. + expect(shell).toMatch(/readTherapyCompareSlugCount\(searchParams\) > 0/); + expect(readTherapyCompareSlugCount(new URLSearchParams("q=trauma&run=1"))).toBe(0); + expect(readTherapyCompareSlugCount(new URLSearchParams("ids=cbt,act"))).toBe(2); + expect(readTherapyCompareSlugCount(new URLSearchParams("ids=,,"))).toBe(0); + }); + + it("caps the counted set at the comparison ceiling", () => { + expect(readTherapyCompareSlugCount(new URLSearchParams("ids=a,b,c,d,e,f"))).toBe(THERAPY_MAX_COMPARE); + }); +}); + describe("reserve resolvers honour the patient-details addon", () => { it("returns the patient-details reserve on the dashboard", () => { expect( @@ -127,6 +163,29 @@ describe("reserve resolvers honour the patient-details addon", () => { ).toBe(mobileComposerVisibleReserve.differentialsCompare); }); + it("returns the therapy-compare reserve in the shell", () => { + expect( + resolveShellVisibleMobileComposerReserve({ + shouldShowSearchComposer: true, + heroOwnsPhoneComposer: false, + searchMode: "therapy-compass", + differentialsCompareAddonActive: false, + therapyCompareAddonActive: true, + }), + ).toBe(mobileComposerVisibleReserve.therapyCompare); + }); + + it("returns the therapy-compare reserve on the dashboard", () => { + expect( + resolveDashboardVisibleMobileComposerReserve({ + searchMode: "therapy-compass", + hasAnswerFollowUps: false, + differentialsCompareAddonActive: false, + therapyCompareAddonActive: true, + }), + ).toBe(mobileComposerVisibleReserve.therapyCompare); + }); + it("keeps the standalone mode home on the idle pad — no dock, no addon reserve", () => { // `/medications` is a standalone mode home: the composer lives in the hero, // so reserving dock-sized space there would open a blank bottom band. @@ -146,6 +205,7 @@ describe("CSS and TS reserve values stay in step", () => { it.each([ ["differentials-compare", mobileComposerVisibleReserve.differentialsCompare], ["patient-details", mobileComposerVisibleReserve.patientDetails], + ["therapy-compare", mobileComposerVisibleReserve.therapyCompare], ] as const)("%s clearance token matches the TS constant", (kind, reserve) => { const token = new RegExp(`--phone-dock-${kind}-clearance:\\s*([\\d.]+)rem`).exec(globalsCss); expect(token, `missing --phone-dock-${kind}-clearance`).not.toBeNull(); diff --git a/tests/recommend-screen.dom.test.tsx b/tests/recommend-screen.dom.test.tsx index 8a724c944a..79759ac2aa 100644 --- a/tests/recommend-screen.dom.test.tsx +++ b/tests/recommend-screen.dom.test.tsx @@ -16,7 +16,9 @@ const bindings = vi.hoisted(() => ({ open: vi.fn(), openSheet: vi.fn(), isInCompare: () => false, - toggleCompare: vi.fn(), + compareSlugs: [], + addCompare: vi.fn(), + removeCompare: vi.fn(), search: { query: "", tags: [] as string[] }, recommendations: [] as Array<{ therapy: Therapy; score: number; reasons: string[] }>, })); diff --git a/tests/therapy-compare-add-in-place.dom.test.tsx b/tests/therapy-compare-add-in-place.dom.test.tsx new file mode 100644 index 0000000000..c45005fa9d --- /dev/null +++ b/tests/therapy-compare-add-in-place.dom.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Therapy } from "@/components/therapy-compass/data/types"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +const tc = vi.hoisted(() => ({ + compareSlugs: [] as string[], + addCompare: vi.fn(), + removeCompare: vi.fn(), + goCompare: vi.fn(), + open: vi.fn(), + openSheet: vi.fn(), +})); + +vi.mock("@/components/therapy-compass/bindings", () => ({ + useTcBindings: () => ({ + search: { query: "CBT", tags: [] }, + compareSlugs: tc.compareSlugs, + isInCompare: (slug: string) => tc.compareSlugs.includes(slug), + addCompare: tc.addCompare, + removeCompare: tc.removeCompare, + goCompare: tc.goCompare, + open: tc.open, + openSheet: tc.openSheet, + }), +})); + +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ isAuthenticated: false, isSaved: () => false, setFavourite: vi.fn() }), +})); + +import { TherapyCompareAction } from "@/components/therapy-compass/record/compare-action"; +import { ResultCard } from "@/components/therapy-compass/therapy-card"; + +const therapy = { + slug: "cognitive-behavioural-therapy", + name: "Cognitive behavioural therapy", + aliases: ["CBT"], + tags: ["CBT"], + category: "Behavioural", + reviewStatus: "needs_review", + briefInterventionAvailable: true, + patientSheetAvailable: true, +} as Therapy; + +beforeEach(() => { + tc.compareSlugs = []; +}); + +afterEach(() => { + tc.addCompare.mockReset(); + tc.removeCompare.mockReset(); + tc.goCompare.mockReset(); +}); + +describe("Adding a therapy to the comparison", () => { + describe("from a search-result card", () => { + it("adds in place and does not navigate to the comparison", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add to compare" })); + + expect(tc.addCompare).toHaveBeenCalledWith(therapy.slug); + expect(tc.goCompare).not.toHaveBeenCalled(); + }); + + it("says the therapy is in the tray, and removes it on a second tap", async () => { + tc.compareSlugs = [therapy.slug]; + const user = userEvent.setup(); + render(); + + const control = screen.getByRole("button", { name: "In compare tray" }); + expect(control).toHaveAttribute("aria-pressed", "true"); + await user.click(control); + + expect(tc.removeCompare).toHaveBeenCalledWith(therapy.slug); + }); + + it("states why a full tray cannot take another, and keeps the control reachable", async () => { + tc.compareSlugs = ["a", "b", "c", "d"].slice(0, THERAPY_MAX_COMPARE); + const user = userEvent.setup(); + render(); + + const control = screen.getByRole("button", { name: "Compare tray full" }); + // `aria-disabled`, never native `disabled`: the reason has to stay reachable. + expect(control).toHaveAttribute("aria-disabled", "true"); + expect(control).not.toBeDisabled(); + expect(control).toHaveAttribute("title", expect.stringContaining("remove one first")); + + await user.click(control); + expect(tc.addCompare).not.toHaveBeenCalled(); + }); + }); + + describe("from a therapy record", () => { + it("announces the add, because no tray is on screen to show it", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add to compare" })); + + expect(tc.addCompare).toHaveBeenCalledWith(therapy.slug); + expect(screen.getByRole("status")).toHaveTextContent( + `Added to compare — 1 of ${THERAPY_MAX_COMPARE} selected to compare.`, + ); + }); + + it("announces a removal too", async () => { + tc.compareSlugs = [therapy.slug, "other"]; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "In compare tray" })); + + expect(tc.removeCompare).toHaveBeenCalledWith(therapy.slug); + expect(screen.getByRole("status")).toHaveTextContent( + `Removed from compare — 1 of ${THERAPY_MAX_COMPARE} selected to compare.`, + ); + }); + + it("offers the comparison only once there is something to compare", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + expect(screen.queryByRole("button", { name: /^Compare \d/ })).toBeNull(); + + tc.compareSlugs = [therapy.slug, "other"]; + rerender(); + await user.click(screen.getByRole("button", { name: "Compare 2" })); + + expect(tc.goCompare).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/therapy-compare-memory.dom.test.tsx b/tests/therapy-compare-memory.dom.test.tsx new file mode 100644 index 0000000000..f4025d29d3 --- /dev/null +++ b/tests/therapy-compare-memory.dom.test.tsx @@ -0,0 +1,166 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TcProvider, useTcBindings } from "@/components/therapy-compass/bindings"; +import { + resetTherapyCompareMemoryForTests, + therapyCompareMemoryStorageKey, + writeTherapyCompareMemory, +} from "@/lib/therapy-compare-memory"; + +const navState = vi.hoisted(() => ({ + pathname: "/therapy-compass/search", + search: "q=trauma&run=1", + push: vi.fn(), + replace: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useSearchParams: () => new URLSearchParams(navState.search), + useRouter: () => ({ push: navState.push, replace: navState.replace, prefetch: () => {} }), +})); + +function therapy(slug: string) { + return { + slug, + name: slug.toUpperCase(), + category: "Standard Talking Therapies", + tags: [], + aliases: [], + warnings: [], + sources: [], + patientSheetTemplates: [], + clinicianScripts: [], + reviewChecklist: null, + reviewStatus: "needs_review", + patientSheetAvailable: false, + briefInterventionAvailable: false, + }; +} + +const CATALOGUE = [therapy("cbt"), therapy("act"), therapy("emdr")]; + +function stubCatalogue(therapies: unknown[] = CATALOGUE) { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + const body = String(url).includes("pathways") || String(url).includes("reference") ? [] : therapies; + return Promise.resolve({ ok: true, status: 200, json: vi.fn().mockResolvedValue(body) }); + }), + ); +} + +function CompareProbe() { + const b = useTcBindings(); + return
{b.compareSlugs.join(",")}
; +} + +function renderProvider() { + return render( + + + , + ); +} + +/** The `ids` the provider asked the router to write, if any. */ +function replacedIds(): string | null { + for (const call of navState.replace.mock.calls) { + const href = String(call[0]); + const query = href.includes("?") ? href.slice(href.indexOf("?") + 1) : ""; + const ids = new URLSearchParams(query).get("ids"); + if (ids) return ids; + } + return null; +} + +beforeEach(() => { + window.localStorage.clear(); + resetTherapyCompareMemoryForTests(); + navState.pathname = "/therapy-compass/search"; + navState.search = "q=trauma&run=1"; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + navState.push.mockReset(); + navState.replace.mockReset(); +}); + +describe("Therapy compare set — device memory", () => { + it("restores the remembered set when the URL carries no ids", async () => { + writeTherapyCompareMemory(["cbt", "act"]); + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toHaveTextContent("cbt,act")); + expect(replacedIds()).toBe("cbt,act"); + }); + + it("lets a shared link win over whatever this device remembers", async () => { + writeTherapyCompareMemory(["cbt", "act"]); + navState.search = "q=trauma&run=1&ids=emdr"; + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toHaveTextContent("emdr")); + // Nothing may rewrite `ids` away from what the link asked for. + expect(replacedIds() === null || replacedIds() === "emdr").toBe(true); + expect(screen.getByTestId("set")).not.toHaveTextContent("cbt"); + }); + + it("drops a remembered slug that is no longer in the catalogue", async () => { + writeTherapyCompareMemory(["cbt", "retired-therapy"]); + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toHaveTextContent("cbt")); + expect(screen.getByTestId("set")).not.toHaveTextContent("retired-therapy"); + }); + + it("restores nothing when no remembered slug survives catalogue validation", async () => { + writeTherapyCompareMemory(["retired-one", "retired-two"]); + stubCatalogue(); + renderProvider(); + + await waitFor(() => expect(screen.getByTestId("set")).toBeInTheDocument()); + expect(screen.getByTestId("set")).toHaveTextContent(""); + expect(replacedIds()).toBeNull(); + }); + + it("does not restore before the catalogue has loaded", () => { + writeTherapyCompareMemory(["cbt", "act"]); + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + renderProvider(); + + expect(screen.getByTestId("set")).toHaveTextContent(""); + expect(replacedIds()).toBeNull(); + }); + + it("mirrors the live set into storage once the restore has decided", async () => { + navState.search = "q=trauma&run=1&ids=emdr"; + stubCatalogue(); + renderProvider(); + + await waitFor(() => + expect(JSON.parse(window.localStorage.getItem(therapyCompareMemoryStorageKey) ?? "null")).toEqual(["emdr"]), + ); + }); + + it("never writes storage before the restore has decided", () => { + writeTherapyCompareMemory(["cbt", "act"]); + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + renderProvider(); + + // The remembered set must survive a mount that never finished loading — + // writing the empty starting set here is what would silently destroy it. + expect(JSON.parse(window.localStorage.getItem(therapyCompareMemoryStorageKey) ?? "null")).toEqual(["cbt", "act"]); + }); +}); diff --git a/tests/therapy-compare-memory.test.ts b/tests/therapy-compare-memory.test.ts new file mode 100644 index 0000000000..e29074be7c --- /dev/null +++ b/tests/therapy-compare-memory.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; +import { + normalizeTherapyCompareSlugs, + readTherapyCompareMemory, + resetTherapyCompareMemoryForTests, + therapyCompareMemoryStorageKey, + writeTherapyCompareMemory, +} from "@/lib/therapy-compare-memory"; + +function memoryStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + store, + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }; +} + +function throwingStorage() { + return { + getItem: () => { + throw new Error("site data blocked"); + }, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; +} + +afterEach(() => { + resetTherapyCompareMemoryForTests(); +}); + +describe("therapy compare memory", () => { + describe("normalization", () => { + it("keeps only trimmed, unique, non-empty strings", () => { + expect(normalizeTherapyCompareSlugs([" cbt ", "cbt", "", " ", "act"])).toEqual(["cbt", "act"]); + }); + + it("drops non-string entries rather than coercing them", () => { + expect(normalizeTherapyCompareSlugs(["cbt", 7, null, undefined, { slug: "act" }, ["act"]])).toEqual(["cbt"]); + }); + + it("caps at the same ceiling the URL parser applies", () => { + const slugs = ["a", "b", "c", "d", "e", "f"]; + expect(normalizeTherapyCompareSlugs(slugs)).toHaveLength(THERAPY_MAX_COMPARE); + expect(normalizeTherapyCompareSlugs(slugs)).toEqual(["a", "b", "c", "d"]); + }); + + it("returns an empty list for anything that is not an array", () => { + for (const value of [null, undefined, "cbt", 3, { 0: "cbt" }]) { + expect(normalizeTherapyCompareSlugs(value)).toEqual([]); + } + }); + }); + + describe("read and write", () => { + it("round-trips a set through injected storage", () => { + const storage = memoryStorage(); + expect(writeTherapyCompareMemory(["cbt", "act"], storage)).toEqual(["cbt", "act"]); + expect(readTherapyCompareMemory(storage)).toEqual(["cbt", "act"]); + }); + + it("normalizes on write, so a malformed set cannot be stored", () => { + const storage = memoryStorage(); + writeTherapyCompareMemory(["cbt", "cbt", " act ", "", "d", "e", "f"], storage); + expect(JSON.parse(storage.store.get(therapyCompareMemoryStorageKey) ?? "[]")).toEqual(["cbt", "act", "d", "e"]); + }); + + it("persists an empty set, so Empty survives a reload", () => { + const storage = memoryStorage(); + writeTherapyCompareMemory(["cbt", "act"], storage); + writeTherapyCompareMemory([], storage); + expect(storage.store.get(therapyCompareMemoryStorageKey)).toBe("[]"); + expect(readTherapyCompareMemory(storage)).toEqual([]); + }); + + it("returns an empty set when nothing has been stored", () => { + expect(readTherapyCompareMemory(memoryStorage())).toEqual([]); + }); + + it("returns an empty set rather than throwing on malformed JSON", () => { + const storage = memoryStorage({ [therapyCompareMemoryStorageKey]: "{not json" }); + expect(readTherapyCompareMemory(storage)).toEqual([]); + }); + + it("normalizes on read, so a hand-edited store cannot exceed the cap", () => { + const storage = memoryStorage({ + [therapyCompareMemoryStorageKey]: JSON.stringify(["a", "a", "b", "c", "d", "e"]), + }); + expect(readTherapyCompareMemory(storage)).toEqual(["a", "b", "c", "d"]); + }); + }); + + describe("when browser storage is unavailable", () => { + it("degrades to this session instead of throwing", () => { + const storage = throwingStorage(); + expect(() => writeTherapyCompareMemory(["cbt", "act"], storage)).not.toThrow(); + expect(readTherapyCompareMemory(storage)).toEqual(["cbt", "act"]); + }); + + it("does not leak the session fallback between tests", () => { + writeTherapyCompareMemory(["cbt"], throwingStorage()); + resetTherapyCompareMemoryForTests(); + expect(readTherapyCompareMemory(throwingStorage())).toEqual([]); + }); + }); +}); diff --git a/tests/therapy-compare-phone-layout.dom.test.tsx b/tests/therapy-compare-phone-layout.dom.test.tsx new file mode 100644 index 0000000000..a7db951d0a --- /dev/null +++ b/tests/therapy-compare-phone-layout.dom.test.tsx @@ -0,0 +1,147 @@ +// The comparison has two presentations of one `rows` memo: the wide table from +// `md` up, and a stacked per-field layout below it. jsdom applies no Tailwind, +// so BOTH render here — every query has to say which one it means. + +import { render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { TcProvider } from "@/components/therapy-compass/bindings"; +import { CompareScreen } from "@/components/therapy-compass/screens/compare-screen"; + +const navigation = vi.hoisted(() => ({ + pathname: "/therapy-compass/compare", + search: "ids=alpha,beta&comparison=all", +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => navigation.pathname, + useSearchParams: () => new URLSearchParams(navigation.search), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), +})); + +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ isAuthenticated: false, isSaved: () => false, setFavourite: vi.fn(async () => true) }), +})); + +const therapy = vi.hoisted(() => (slug: string, name: string, caution: string) => ({ + slug, + name, + category: "Skills based", + clinicalSummary: `${name} summary`, + bestUsedFor: `${name} best fit`, + indications: "Anxiety", + contraindicationsOrCautions: caution, + deliverySteps: "1. Orient the patient", + patientExplanation: "A practical therapy skill.", + sourceNotes: null, + targetSymptoms: "Anxiety", + patientPopulation: "Adults", + setting: "Outpatient", + sessionLength: "50 minutes", + timeRequired: "5 minutes", + complexity: "Low", + mechanism: "Skills practice", + briefVersion: null, + fifteenMinuteVersion: null, + fullSessionVersion: null, + homework: null, + materials: null, + commonPitfalls: null, + alternatives: null, + relatedTherapies: null, + evidenceLevel: "Review source", + evidenceNotes: null, + limitations: null, + references: null, + reviewStatus: "reviewed", + confidenceLevel: null, + contentOrigin: null, + patientSheetAvailable: true, + briefInterventionAvailable: true, + sourceCompleteness: null, + indexCompleteness: null, + reviewCompleteness: null, + tags: ["Anxiety"], + warnings: [], + aliases: [], + sources: [], + patientSheetTemplates: [], + clinicianScripts: [], + reviewChecklist: null, +})); + +vi.mock("@/components/therapy-compass/data/use-therapy-data", () => ({ + useTherapyData: () => ({ + data: { + therapies: [ + therapy("alpha", "Alpha therapy", "Alpha caution."), + therapy("beta", "Beta therapy", "Beta caution."), + ], + pathways: [], + reference: { categories: [], tags: [], measures: [] }, + }, + loading: false, + error: null, + retry: vi.fn(), + }), +})); + +function renderCompare() { + return render( + + + , + ); +} + +afterEach(() => { + navigation.search = "ids=alpha,beta&comparison=all"; +}); + +describe("Therapy comparison on a phone", () => { + it("forks at md, so the 720px table never has to scroll sideways on a phone", () => { + renderCompare(); + + // The table is `min-w-[720px]`; at 640–767px it would still scroll, which is + // why the fork is `md` rather than `sm`. + expect(screen.getByTestId("therapy-compare-table").className).toContain("hidden"); + expect(screen.getByTestId("therapy-compare-table").className).toContain("md:block"); + expect(screen.getByTestId("therapy-compare-stack").className).toContain("md:hidden"); + }); + + it("keeps each field label with its values instead of scrolling them apart", () => { + renderCompare(); + const stack = within(screen.getByTestId("therapy-compare-stack")); + + // One card per field, and every selected therapy appears inside that card — + // the label cannot leave the values it labels. + const caution = stack.getByRole("region", { name: "When not to use" }); + expect(within(caution).getByText("Alpha therapy")).toBeInTheDocument(); + expect(within(caution).getByText("Beta therapy")).toBeInTheDocument(); + expect(within(caution).getByText("Alpha caution.")).toBeInTheDocument(); + expect(within(caution).getByText("Beta caution.")).toBeInTheDocument(); + + const fit = stack.getByRole("region", { name: "Best fit" }); + expect(within(fit).getByText("Alpha therapy best fit")).toBeInTheDocument(); + }); + + it("shows the phone layout the same rows the tab filter chose", () => { + navigation.search = "ids=alpha,beta&comparison=priorities"; + renderCompare(); + const stack = within(screen.getByTestId("therapy-compare-stack")); + + expect(stack.getByRole("region", { name: "When not to use" })).toBeInTheDocument(); + expect(stack.getByRole("region", { name: "Evidence level" })).toBeInTheDocument(); + // "Time required" is not a priority row, so neither presentation shows it. + expect(stack.queryByRole("region", { name: "Time required" })).toBeNull(); + expect(within(screen.getByTestId("therapy-compare-table")).queryByText("Time required")).toBeNull(); + }); + + it("draws no comparison at all — and no empty slots — below two therapies", () => { + navigation.search = "ids=alpha"; + renderCompare(); + + expect(screen.queryByTestId("therapy-compare-stack")).toBeNull(); + expect(screen.queryByTestId("therapy-compare-table")).toBeNull(); + }); +}); diff --git a/tests/therapy-compare-tray.dom.test.tsx b/tests/therapy-compare-tray.dom.test.tsx new file mode 100644 index 0000000000..834a56b409 --- /dev/null +++ b/tests/therapy-compare-tray.dom.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { installMatchMediaStub } from "./setup/jsdom.setup"; + +import type { Therapy } from "@/components/therapy-compass/data/types"; +import { therapyCompareAddonSlotId } from "@/lib/mode-home-composer"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +const tc = vi.hoisted(() => ({ + compareSlugs: [] as string[], + compareTherapies: [] as Therapy[], + removeCompare: vi.fn(), + clearCompare: vi.fn(), + goCompare: vi.fn(), +})); + +vi.mock("@/components/therapy-compass/bindings", () => ({ + useTcBindings: () => ({ + compareSlugs: tc.compareSlugs, + compareTherapies: tc.compareTherapies, + removeCompare: tc.removeCompare, + clearCompare: tc.clearCompare, + goCompare: tc.goCompare, + }), +})); + +import { TherapyCompareTray } from "@/components/therapy-compass/therapy-compare-tray"; + +function therapy(slug: string, name: string, aliases: string[] = []): Therapy { + return { slug, name, aliases, tags: [], category: "Behavioural" } as unknown as Therapy; +} + +const CBT = therapy("cbt", "Cognitive behavioural therapy", ["CBT"]); +const ACT = therapy("act", "Acceptance and commitment therapy", ["ACT"]); + +function select(...items: Therapy[]) { + tc.compareTherapies = items; + tc.compareSlugs = items.map((item) => item.slug); +} + +/** The dock renders the addon slot inside its own form; stand one in for it. */ +function mountDockSlot() { + const slot = document.createElement("div"); + slot.id = therapyCompareAddonSlotId; + document.body.append(slot); + return slot; +} + +beforeEach(() => { + // The tray gates on the phone breakpoint the dock itself uses (639px). + installMatchMediaStub(true); + select(); +}); + +afterEach(() => { + document.getElementById(therapyCompareAddonSlotId)?.remove(); + tc.removeCompare.mockReset(); + tc.clearCompare.mockReset(); + tc.goCompare.mockReset(); +}); + +describe("Therapy compare tray", () => { + it("does not exist until something is in it", () => { + mountDockSlot(); + render(); + + expect(screen.queryByTestId("therapy-compare-tray")).toBeNull(); + }); + + it("renders nothing above the phone breakpoint, where there is no dock to sit in", () => { + installMatchMediaStub(false); + mountDockSlot(); + select(CBT, ACT); + render(); + + expect(screen.queryByTestId("therapy-compare-tray")).toBeNull(); + }); + + it("docks inside the composer's addon slot rather than positioning itself", async () => { + const slot = mountDockSlot(); + select(CBT); + render(); + + const tray = await screen.findByTestId("therapy-compare-tray"); + // Inheriting the dock's fixed position, z-index, safe-area and scroll-hide + // transform is the entire reason this is a portal and not new chrome. + expect(slot.contains(tray)).toBe(true); + }); + + it("holds Compare back until there are two, and says why", async () => { + mountDockSlot(); + select(CBT); + render(); + + const compare = await screen.findByTestId("therapy-compare-tray-compare"); + expect(compare).toHaveAttribute("aria-disabled", "true"); + expect(compare).not.toBeDisabled(); + expect(compare).toHaveAttribute("title", "Add one more therapy to compare"); + + await userEvent.click(compare); + expect(tc.goCompare).not.toHaveBeenCalled(); + }); + + it("opens the comparison once two are selected", async () => { + mountDockSlot(); + select(CBT, ACT); + render(); + + const compare = await screen.findByTestId("therapy-compare-tray-compare"); + expect(compare).not.toHaveAttribute("aria-disabled"); + await userEvent.click(compare); + + expect(tc.goCompare).toHaveBeenCalledTimes(1); + }); + + it("shows the record's abbreviation where it has one, and counts the set", async () => { + mountDockSlot(); + select(CBT, ACT); + render(); + + const tray = await screen.findByTestId("therapy-compare-tray"); + expect(tray).toHaveTextContent("CBT · ACT"); + expect(tray).toHaveTextContent(`2 of ${THERAPY_MAX_COMPARE} selected`); + expect(screen.getByTestId("therapy-compare-tray-open")).toHaveAttribute( + "aria-label", + `Compare tray, 2 of ${THERAPY_MAX_COMPARE} selected`, + ); + }); + + it("removes and empties from the sheet, not from the one-row bar", async () => { + mountDockSlot(); + select(CBT, ACT); + render(); + + await userEvent.click(await screen.findByTestId("therapy-compare-tray-open")); + await screen.findByTestId("therapy-compare-tray-sheet"); + + await userEvent.click(await screen.findByRole("button", { name: `Remove ${CBT.name} from the comparison` })); + expect(tc.removeCompare).toHaveBeenCalledWith(CBT.slug); + // Two were selected, so one removal leaves a tray — the sheet stays put. + expect(screen.getByTestId("therapy-compare-tray-sheet")).toBeInTheDocument(); + + // Emptying does not: the sheet belongs to a tray that is about to stop + // existing, so leaving it open would be a dialog over nothing. + await userEvent.click(await screen.findByRole("button", { name: "Empty the tray" })); + expect(tc.clearCompare).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByTestId("therapy-compare-tray-sheet")).toBeNull()); + }); + + it("closes the sheet when the tray it belongs to stops existing", async () => { + mountDockSlot(); + select(CBT, ACT); + const { rerender } = render(); + + await userEvent.click(await screen.findByTestId("therapy-compare-tray-open")); + expect(await screen.findByTestId("therapy-compare-tray-sheet")).toBeInTheDocument(); + + select(); + rerender(); + + await waitFor(() => expect(screen.queryByTestId("therapy-compare-tray-sheet")).toBeNull()); + }); +}); diff --git a/tests/therapy-compass-compare-url.dom.test.tsx b/tests/therapy-compass-compare-url.dom.test.tsx index 451c876c57..f6897ea7ea 100644 --- a/tests/therapy-compass-compare-url.dom.test.tsx +++ b/tests/therapy-compass-compare-url.dom.test.tsx @@ -26,14 +26,18 @@ afterEach(() => { function CompareProbe() { const bindings = useTcBindings(); return ( - ); } describe("Therapy comparison URL identity", () => { - it("serializes the exact selected therapy slug while preserving search context", () => { + // Adding deliberately no longer navigates. The compare set is assembled from + // wherever the therapies are — a result list, a record — and the reader stays + // put; only the address bar changes, so the set is still shareable and still + // survives a reload. This test asserts that changed contract on purpose. + it("serializes the exact selected therapy slug without leaving the page", () => { vi.stubGlobal( "fetch", vi.fn(() => new Promise(() => {})), @@ -46,9 +50,10 @@ describe("Therapy comparison URL identity", () => { fireEvent.click(screen.getByRole("button", { name: "Compare exact therapy" })); - expect(navState.push).toHaveBeenCalledTimes(1); - const destination = new URL(String(navState.push.mock.calls[0]?.[0]), "http://localhost"); - expect(destination.pathname).toBe("/therapy-compass/compare"); + expect(navState.push).not.toHaveBeenCalled(); + const written = navState.replace.mock.calls.at(-1)?.[0]; + const destination = new URL(String(written), "http://localhost"); + expect(destination.pathname).toBe("/therapy-compass/search"); expect(destination.searchParams.get("q")).toBe("CBT"); expect(destination.searchParams.get("run")).toBe("1"); expect(destination.searchParams.get("ids")).toBe("cognitive-behavioural-therapy-cbt"); diff --git a/tests/therapy-compass-responsive-contract.test.ts b/tests/therapy-compass-responsive-contract.test.ts index 1e4fe906c1..069bf54793 100644 --- a/tests/therapy-compass-responsive-contract.test.ts +++ b/tests/therapy-compass-responsive-contract.test.ts @@ -132,10 +132,14 @@ describe("Therapy Compass responsive contract", () => { expect(workspaceSource).not.toContain("sm:px-10"); }); - it("keeps phone reflow and comparison scroll residuals in globals.css", () => { + it("keeps phone reflow residuals in globals.css", () => { expect(globalsSource).toMatch(/@media \(max-width: 640px\)/); - expect(globalsSource).toContain("overflow-x: auto !important;"); - expect(globalsSource).toContain("[data-therapy-scroll-sm]"); + expect(globalsSource).toContain(".therapy-pathway-list"); + // `[data-therapy-scroll-sm]` was the phone horizontal-scroll enabler for the + // comparison table. Phones no longer render that table at all — they get the + // stacked per-field layout below `md` — so the rule and the attribute were + // removed together rather than left as a rule nothing can match. + expect(globalsSource).not.toContain("[data-therapy-scroll-sm]"); }); it("marks every fixed screen/card grid for phone reflow without changing its desktop template", () => { @@ -167,7 +171,15 @@ describe("Therapy Compass responsive contract", () => { expect(responsiveStackCount(compareSource)).toBeGreaterThanOrEqual(1); expect(compareSource).toContain(" ({ isInCompare: () => false, open: vi.fn(), openSheet: vi.fn(), - toggleCompare: vi.fn(), + compareSlugs: [], + addCompare: vi.fn(), + removeCompare: vi.fn(), }), })); diff --git a/tests/therapy-favourite.dom.test.tsx b/tests/therapy-favourite.dom.test.tsx index 07ed719a67..043e0f04e6 100644 --- a/tests/therapy-favourite.dom.test.tsx +++ b/tests/therapy-favourite.dom.test.tsx @@ -23,7 +23,9 @@ vi.mock("@/components/therapy-compass/bindings", () => ({ isInCompare: () => false, open: vi.fn(), openSheet: vi.fn(), - toggleCompare: vi.fn(), + compareSlugs: [], + addCompare: vi.fn(), + removeCompare: vi.fn(), }), })); diff --git a/tests/therapy-record-nav.dom.test.tsx b/tests/therapy-record-nav.dom.test.tsx index 647d2a77c8..39c30c7a76 100644 --- a/tests/therapy-record-nav.dom.test.tsx +++ b/tests/therapy-record-nav.dom.test.tsx @@ -13,7 +13,6 @@ const bindings = vi.hoisted(() => ({ open: vi.fn(), openSheet: vi.fn(), openBrief: vi.fn(), - toggleCompare: vi.fn(), goCompare: vi.fn(), removeCompare: vi.fn(), inCompare: false, @@ -24,7 +23,6 @@ vi.mock("@/components/therapy-compass/bindings", () => ({ open: bindings.open, openSheet: bindings.openSheet, openBrief: bindings.openBrief, - toggleCompare: bindings.toggleCompare, goCompare: bindings.goCompare, removeCompare: bindings.removeCompare, isInCompare: () => bindings.inCompare, @@ -110,27 +108,29 @@ describe("therapy record header", () => { expect(bindings.openBrief).toHaveBeenCalledWith(therapy.slug); }); - it("adds the therapy to the comparison before opening it, but only once", async () => { + // Every other slot in this rail is a destination, and this one now is too. + // It used to add the therapy when it was absent from the set and navigate + // when it was present — the same tap doing two different things depending on + // state the reader could not see. Adding moved to the record's own button. + it("navigates to the comparison whether or not the therapy is in it", async () => { const user = userEvent.setup(); const { unmount } = renderHeader(); await user.click( within(screen.getByTestId("therapy-detail-section-rail")).getByRole("button", { name: /^Compare/ }), ); - expect(bindings.toggleCompare).toHaveBeenCalledWith(therapy.slug); - expect(bindings.goCompare).not.toHaveBeenCalled(); + expect(bindings.goCompare).toHaveBeenCalledTimes(1); + expect(bindings.removeCompare).not.toHaveBeenCalled(); unmount(); - // Already in the comparison: toggling again would remove it, so an - // established member just navigates. bindings.inCompare = true; - bindings.toggleCompare.mockReset(); + bindings.goCompare.mockReset(); renderHeader(); await user.click( within(screen.getByTestId("therapy-detail-section-rail")).getByRole("button", { name: /^Compare/ }), ); - expect(bindings.goCompare).toHaveBeenCalled(); - expect(bindings.toggleCompare).not.toHaveBeenCalled(); + expect(bindings.goCompare).toHaveBeenCalledTimes(1); + expect(bindings.removeCompare).not.toHaveBeenCalled(); }); it("does not re-navigate when the active destination is selected", async () => { diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index c119ecb01f..1109050049 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -387,7 +387,7 @@ test.describe("previously uncovered production routes", () => { await page.setViewportSize({ width: 390, height: 844 }); await page.emulateMedia({ reducedMotion: "reduce" }); const compare = card.locator("[data-therapy-result-actions] button").nth(1); - await expect(compare).toHaveAccessibleName("Compare"); + await expect(compare).toHaveAccessibleName("Add to compare"); await compare.focus(); const focusStyle = await compare.evaluate((element) => { const style = getComputedStyle(element); @@ -404,13 +404,18 @@ test.describe("previously uncovered production routes", () => { await expectNoHorizontalOverflow(page); await expect(card.locator("[data-therapy-result-actions] button")).toHaveCount(3); + // Adding deliberately keeps the reader where they are. The set moves into + // the URL (so it is still shareable and survives a reload) and into the tray + // above the composer; the page does not change. await compare.focus(); await page.keyboard.press("Space"); - await expect(page).toHaveURL(/\/therapy-compass\/compare(?:\?.*)?$/); - const comparisonUrl = new URL(page.url()); - expect(comparisonUrl.searchParams.get("q")).toBe("CBT"); - expect(comparisonUrl.searchParams.get("ids")).toBeTruthy(); - await expect(page.getByRole("heading", { name: "Therapy Comparison", level: 1 })).toBeVisible(); + await expect(compare).toHaveAccessibleName("In compare tray"); + await expect(page).toHaveURL(/\/therapy-compass\/search/); + const stayedPut = new URL(page.url()); + expect(stayedPut.searchParams.get("q")).toBe("CBT"); + expect(stayedPut.searchParams.get("ids")).toBeTruthy(); + await expect(page.getByTestId("therapy-compare-tray")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Therapy Comparison", level: 1 })).toHaveCount(0); }); // `/dsm` redirects onto the shared home, so the route this proves is the shared diff --git a/tests/ui-therapy-nav-scroll.spec.ts b/tests/ui-therapy-nav-scroll.spec.ts index aeb4609bc9..1838d8a87f 100644 --- a/tests/ui-therapy-nav-scroll.spec.ts +++ b/tests/ui-therapy-nav-scroll.spec.ts @@ -54,6 +54,22 @@ async function gotoTherapyCompare(page: Page) { await page.waitForTimeout(700); } +/** Two real therapy slugs, enough to make the tray offer a comparison. */ +const COMPARE_SLUGS = ["cognitive-behavioural-therapy-cbt", "acceptance-and-commitment-therapy-act"]; + +async function readTrayGeometry(page: Page) { + return page.evaluate(() => { + const tray = document.querySelector('[data-testid="therapy-compare-tray"]'); + const main = document.querySelector("#main-content"); + const rect = tray?.getBoundingClientRect(); + return { + trayTop: rect?.top ?? Number.POSITIVE_INFINITY, + trayBottom: rect?.bottom ?? Number.POSITIVE_INFINITY, + reserve: main ? getComputedStyle(main).getPropertyValue("--mobile-composer-reserve").trim() : "", + }; + }); +} + test.beforeEach(async ({ page }) => { await blockExternalRequests(page); await installTherapyFixtures(page); @@ -126,3 +142,59 @@ test("phone Therapy mode nav hides and returns with the universal header", async expect(box!.y).toBeGreaterThanOrEqual(0); expect(box!.height).toBeGreaterThan(8); }); + +/** + * The compare tray docks INSIDE the phone search dock's form, so it inherits the + * dock's fixed position and — the clause most likely to be broken by a later + * edit — its scroll-hide transform. If it ever became independently fixed + * chrome, it would stay pinned over the page after the composer scrolled away, + * and the content reserve would no longer describe what is on screen. + */ +test("phone compare tray hides with the composer and releases its reserve", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await page.goto(`/therapy-compass/search?q=CBT&run=1&ids=${COMPARE_SLUGS.join(",")}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 }); + await page.addStyleTag({ + content: ":root{--safe-area-top:59px !important;--safe-area-bottom:34px !important;}", + }); + + const tray = page.getByTestId("therapy-compare-tray"); + const dock = page.locator(".answer-footer-search-dock"); + await expect(tray).toBeVisible({ timeout: 15_000 }); + + // Docked, not free-floating: the tray is a descendant of the composer's form. + await expect + .poll(async () => tray.evaluate((node) => Boolean(node.closest(".answer-footer-search-dock")))) + .toBe(true); + await expect(dock).toHaveAttribute("data-footer-addon", "therapy-compare"); + + const visible = await readTrayGeometry(page); + expect(visible.trayBottom).toBeLessThanOrEqual(phoneViewport.height + 1); + expect(visible.reserve, "a claimed dock reserves room for the tray").not.toBe("0rem"); + + await appendPrimaryScrollSpacer(page, { heightPx: 2400, testId: "therapy-tray-hide-scroll-spacer" }); + for (const offset of [40, 80, 120, 160, 220, 300]) { + await scrollPrimarySurface(page, offset); + } + + await expect(dock).toHaveAttribute("data-scroll-hidden", "true", { timeout: 5_000 }); + await expect + .poll(async () => (await readTrayGeometry(page)).trayTop, { timeout: 5_000 }) + .toBeGreaterThanOrEqual(phoneViewport.height - 1); + // Hidden means zero reserve — never the safe-area inset, which on iOS Safari + // recreates a toolbar-sized blank band under the content. + await expect.poll(async () => (await readTrayGeometry(page)).reserve, { timeout: 5_000 }).toBe("0rem"); + + for (const offset of [220, 140, 60, 0]) { + await scrollPrimarySurface(page, offset); + } + + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true", { timeout: 5_000 }); + await expect(tray).toBeVisible(); + const returned = await readTrayGeometry(page); + expect(returned.trayBottom).toBeLessThanOrEqual(phoneViewport.height + 1); + expect(returned.reserve).not.toBe("0rem"); +}); From ecae1752e52dd2cd363bfaaafe2491df24769606 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:01:20 +0000 Subject: [PATCH 2/4] Record the therapy compare tray review in the ledger Travels with its owning product PR rather than on a ledger-only tip, per AGENTS.md PR bundling. Pushed seconds after the PR opened so the restarted CI run loses no meaningful work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WKxKU17WQqryM8iNhpyJk7 --- ...c0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/e9e97c01e7bcec0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md diff --git a/docs/branch-review-records/e9e97c01e7bcec0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md b/docs/branch-review-records/e9e97c01e7bcec0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md new file mode 100644 index 0000000000..331ec71a2c --- /dev/null +++ b/docs/branch-review-records/e9e97c01e7bcec0aa0ee1416a488f8890818ce71281b5d5841a814a017eca245.record.md @@ -0,0 +1 @@ +| 2026-08-26 | claude/therapy-compare-tray | 9b34a0149759b2c2f1e8ed5d37f02ba1ac35cf39 | therapy compare tray: phone dock addon, add-in-place, stacked comparison, device memory | built and verified; verify:cheap exit 0 (876 files / 10547 tests), verify:phone-chrome escalated to full Chromium 521 passed | verify:cheap, verify:phone-chrome (full verify:ui), lint, typecheck | From d79d2c09511b09c7308a7fe83eb38913896f2472 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:03:45 +0000 Subject: [PATCH 3/4] Pin the therapy compare storage-key Gitleaks false positive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitleaks' generic-api-key rule flagged the exported browser-storage key name for the compare set. It is a localStorage key — visible in any browser's devtools, never a credential. The rule matched only because the identifier ends in "Key" and the hyphenated slug measures 3.99 Shannon entropy; its five siblings (answer-thread, recent-queries, saved-*, search-pins) are the same shape at 3.72-3.78, just under the threshold. Pinned by fingerprint rather than reworded. Rewording would be tuning an unverifiable number — gitleaks is not installed in this container, so the threshold cannot be checked locally — and every candidate slug still measured above the passing siblings, so it would trade a good name for a worse one and no certainty. The .gitleaksignore file exists for exactly this, with ten prior entries against the same rule. The scanner is not weakened: this pins one commit's one line, not the rule or the path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WKxKU17WQqryM8iNhpyJk7 --- .gitleaksignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index 12d83edba6..bb76d93f3c 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -67,3 +67,15 @@ a394d76631c7ea3405037baf58091b1c4c47fa3f:tests/codex-cloud-setup.test.ts:generic 14535ffc5f05d559f6bccac84099f9e6874bba6a:.gitleaksignore:generic-api-key:49 # Reviewed false positive: a localhost example URL with a dummy API key in a removed handoff document. 92097e53e096bc33c8b23f73874981b2e3214af3:docs/ed-care-plans/claude-build-handover-2026-08-21.md:generic-api-key:360 +# Reviewed false positive: the exported browser-storage key name for the Therapy +# compare set. It is a `localStorage` key, visible in any browser's devtools and +# never a credential; `generic-api-key` matched it only because the identifier +# ends in "Key" and the hyphenated slug measures 3.99 Shannon entropy. Its five +# siblings (answer-thread, recent-queries, saved-*, search-pins) are the same +# shape and sit at 3.72-3.78, just under the rule's threshold, so this is a +# margin artefact rather than a new class of finding. Rewording the slug to duck +# the threshold was rejected: it is tuning an unverifiable number (gitleaks is +# not installed locally) and would leave a worse name behind. The value is not +# quoted in this comment, because a previous allowlist entry that quoted its own +# pattern tripped the same rule (see line 49 above). +9b34a0149759b2c2f1e8ed5d37f02ba1ac35cf39:src/lib/therapy-compare-memory.ts:generic-api-key:24 From 36a50c279cc17f5ea7647aa7cd73258a82ef6db4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:04:59 +0000 Subject: [PATCH 4/4] Regenerate the repo-awareness snapshot for the added review record Static PR checks went red on `check:repo-awareness-snapshot`: the immutable review record committed alongside this change moves the review count, and the generated inventory is derived from it, so the two must be committed together. Regenerated with the repo's own tooling, never by hand. Verified in step (192 pages, 436 documents, 2617 reviews), with sitemap:check, docs:check-index, check:ledger-write-discipline, check:branch-review-ledger and prettier all clean before pushing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WKxKU17WQqryM8iNhpyJk7 --- data/repo-awareness-snapshot.json | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 9429e92191..46306750bc 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "c391de340e5946a35d5c7d9bf580ebe019e90c31", - "committed_at": "2026-08-26T00:26:15+00:00" + "sha": "ecae1752e52dd2cd363bfaaafe2491df24769606", + "committed_at": "2026-08-26T04:01:20+00:00" }, "routes": { "modes": [ @@ -3640,6 +3640,14 @@ }, "review_state": { "records": [ + { + "date": "2026-08-26", + "ref": "claude/therapy-compare-tray", + "head": "9b34a0149759b2c2f1e8ed5d37f02ba1ac35cf39", + "scope": "therapy compare tray: phone dock addon, add-in-place, stacked comparison, device memory", + "outcome": "built and verified; verify:cheap exit 0 (876 files / 10547 tests), verify:phone-chrome escalated to full Chromium 521 passed", + "checks": "verify:cheap, verify:phone-chrome (full verify:ui), lint, typecheck" + }, { "date": "2026-08-25", "ref": "backup/pr-2333-prelinear-20260824", @@ -24570,8 +24578,8 @@ } ], "counts": { - "records": 2616, - "refs": 1595 + "records": 2617, + "refs": 1596 } } }