From 18e2b7ce97b5e81d219085798a83d92789526b7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:17:15 +0000 Subject: [PATCH 01/12] feat(filters): therapy-compass adopts the filter contract (PR E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converges therapy-compass's bespoke phone-only TherapyFilterSheet / TherapyFilterTrigger onto the shared ResultFilterSheet / ResultFilterTrigger / ResultFilterFacetChips, fixing both invariants the contract doc already named against this mode: - The sheet's Clear all no longer wipes the query. It now uses the shared onClearAll contract (filters only); the composer's own "Clear search" covers the query on every breakpoint, matching every other adopted mode. - The trigger is now the shared ResultFilterTrigger instead of a bespoke reimplementation with a different icon, hardcoded test id, and label-hiding breakpoint. Three facet groups, desktop and phone sharing one array: - Topics (the six curated quick-filter tags) is many-of-N, OR within the group. This also fixed searchTherapies' tag matching, which was AND-within -group — the same defect class already fixed for document tags: a second topic narrowed instead of widening, an accumulating-selection dead affordance. - Review status and Handout are two independent one-option facet groups (Reviewed only / Brief available), not options inside one shared group, because they AND against each other and against Topics rather than OR. matchesTopics/matchesAvailability in data/select.ts are the single predicate both searchTherapies and the sheet's option counts run through, so a count can't drift from what the real filter does. Verification: full unit suite 6167 passed/4 skipped/0 failed; typecheck and lint clean; verify:pr-local 15/15 green (build run with the dev server stopped); bundle-budget production 1304.4 KiB and mockups 294.5 KiB, both within tolerance on a freshness-verified build; browser proof at 320/390/639/768/1440/1920px, 0px overflow at every width, 48px phone tap target, widen-on-multi-select and query-preserving Clear all verified live. --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 4 +- docs/filter-contract.md | 23 +- src/components/therapy-compass/bindings.tsx | 19 ++ src/components/therapy-compass/data/select.ts | 39 +++- .../therapy-compass/filter-sheet.tsx | 175 --------------- .../therapy-compass/screens/search-screen.tsx | 200 +++++++++++------- tests/therapy-compass-mode-wiring.test.ts | 21 +- ...herapy-compass-search-filters.dom.test.tsx | 179 ++++++++++++++++ tests/therapy-filter-sheet.dom.test.tsx | 74 ------- tests/therapy-search-facets.test.ts | 100 +++++++++ tests/ui-accessibility.spec.ts | 35 +-- 12 files changed, 509 insertions(+), 362 deletions(-) delete mode 100644 src/components/therapy-compass/filter-sheet.tsx create mode 100644 tests/therapy-compass-search-filters.dom.test.tsx delete mode 100644 tests/therapy-filter-sheet.dom.test.tsx create mode 100644 tests/therapy-search-facets.test.ts diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 2561da50d4..0af33ce0b6 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1001,7 +1001,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 | 7 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 24 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `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 618868708e..3e549abfca 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1496,7 +1496,6 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/services/service-group-nav.tsx", - "src/components/therapy-compass/filter-sheet.tsx", "src/components/ui/confirm-dialog.tsx" ], "productImportFiles": [ @@ -1523,8 +1522,7 @@ "src/components/forms/forms-search-results-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", - "src/components/services/service-group-nav.tsx", - "src/components/therapy-compass/filter-sheet.tsx" + "src/components/services/service-group-nav.tsx" ], "designSync": { "listedInSourceMap": true, diff --git a/docs/filter-contract.md b/docs/filter-contract.md index 8bdbd51db7..145f0bca48 100644 --- a/docs/filter-contract.md +++ b/docs/filter-contract.md @@ -118,10 +118,10 @@ never become unreachable. - **`footerNote` counts what the filters actually govern.** Specifiers currently reports `results.length + catalogueMatches.length` while the groups narrow only `results` — the sheet claims to scope a list it half controls. A mode must not report a total its filters cannot move. -- **`onClearAll` never touches the query.** Therapy-compass's clear wipes the search box; the - shared sheet's does not. Clearing filters and clearing a search are different intentions. -- **One trigger component.** `ResultFilterTrigger`. Therapy-compass re-implements it with a - different icon, a hardcoded test id and a different label-hiding breakpoint. +- **`onClearAll` never touches the query.** Clearing filters and clearing a search are different + intentions. Therapy-compass's phone sheet wiped the search box until its own adoption (see + Rollout below) converged it onto the shared sheet's `onClearAll`, which does not. +- **One trigger component.** `ResultFilterTrigger`. Every adopted mode uses it now — see Rollout. - **Tap targets are `min-h-tap` (48px) on phone.** Do not relax to 44px for generic WCAG guidance; it reintroduces a known `ui-smoke` flake. @@ -141,6 +141,19 @@ Contract first, then one PR per mode: 2. **Per mode** — adopt the right kind, derive the option list, add counts, and retire that mode's desktop rail so the breakpoints stop disagreeing. 3. **Services and factsheets** — evict the query-replacing presets to the composer. -4. **Documents last** — port its needle and collapse up into the shared component as the +4. **Therapy-compass** — converged its bespoke phone-only `TherapyFilterSheet`/`TherapyFilterTrigger` + (`filter-sheet.tsx`, now deleted) onto the shared component, fixing both invariants named above + in the same change: the sheet's `onClearAll` no longer wipes the query (the composer's own + "Clear search" already covers that, on every breakpoint), and the trigger is now the shared + `ResultFilterTrigger`. Three facet groups: Topics (the six curated quick-filter tags, OR within + the group — this also fixed `searchTherapies`' tag matching, which had been AND-within-group, + the same defect class already fixed for document tags: a second topic narrowed instead of + widening, an accumulating-selection dead affordance) and two independent one-option groups, + Review status and Handout (`Reviewed only` / `Brief available`), which AND against Topics and + against each other — they are not alternatives, so they are not one shared group. See the two + predicates `matchesTopics`/`matchesAvailability` in `data/select.ts`, which `searchTherapies` and + the sheet's option counts both run through, so the count and the real filter cannot drift apart + (section 3). +5. **Documents last** — port its needle and collapse up into the shared component as the `> 20` tier, then converge. It is the largest surface and should move once the contract is proven elsewhere. diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index d4c39556e1..8649b377e7 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -112,6 +112,13 @@ export type TcBindings = { // ---- search --------------------------------------------------------- search: SearchOptions; searchResults: Therapy[]; + /** + * Query-only matches — same query, tags/reviewedOnly/briefOnly reset to + * `EMPTY_SEARCH`. The base for a facet option's count ("how many would I + * have if I ticked this as well" — docs/filter-contract.md section 3), + * which must never be narrowed by the very selection it is counting. + */ + queryMatches: Therapy[]; setQuery: (q: string) => void; submitQuery: (q: string) => void; // set query + go search toggleTag: (tag: string) => void; @@ -321,6 +328,16 @@ export function TcProvider({ children }: { children: ReactNode }) { // match aria-pressed state without waiting for useDeferredValue. return searchTherapies(therapies, { ...search, query: deferredSearch.query }); }, [therapies, deferredSearch.query, search]); + // Same query-deferral shape as `searchResults`, tags/reviewedOnly/briefOnly + // reset — so a facet count never lags behind or races ahead of the query + // typing the reader can see. + const queryMatches = useMemo(() => { + const liveQuery = search.query.trim(); + const deferredQuery = deferredSearch.query.trim(); + if (!liveQuery) return searchTherapies(therapies, EMPTY_SEARCH); + if (!deferredQuery) return []; + return searchTherapies(therapies, { ...EMPTY_SEARCH, query: deferredSearch.query }); + }, [therapies, deferredSearch.query, search.query]); const compareTherapies = useMemo( () => compareSlugs.map((sl) => bySlug.get(sl)).filter((t): t is Therapy => Boolean(t)), [compareSlugs, bySlug], @@ -422,6 +439,7 @@ export function TcProvider({ children }: { children: ReactNode }) { search, searchResults, + queryMatches, setQuery: (q) => patchSearch({ query: q }), submitQuery: (q) => { patchSearch({ query: q }); @@ -538,6 +556,7 @@ export function TcProvider({ children }: { children: ReactNode }) { relatedForSelected, search, searchResults, + queryMatches, compareSlugs, compareTherapies, recQuery, diff --git a/src/components/therapy-compass/data/select.ts b/src/components/therapy-compass/data/select.ts index cc60850dde..c0321faaa6 100644 --- a/src/components/therapy-compass/data/select.ts +++ b/src/components/therapy-compass/data/select.ts @@ -124,7 +124,7 @@ export function complexityLabel(complexity: string | null): string { export type SearchOptions = { query: string; - tags: string[]; // therapy must carry ALL selected tags + tags: string[]; // therapy must carry ANY selected tag (OR within the group — see matchesTopics) briefOnly: boolean; sheetOnly: boolean; reviewedOnly: boolean; @@ -138,6 +138,36 @@ export const EMPTY_SEARCH: SearchOptions = { reviewedOnly: false, }; +/** + * Topics facet predicate — OR within the group, matching every other adopted + * facet in the app (docs/filter-contract.md section 1). Picking both CBT and + * DBT means "either", not "a therapy tagged with both": AND-within-group is + * the exact defect class already fixed for document tags, where it made a + * second selection within one group a dead affordance instead of a widen. + * + * Exported so the filter sheet's option counts (`searchTherapies`'s own + * `hint` predicate) and the real filter run through one function — the count + * must be produced by the same predicate as the filter, or the two drift + * apart the moment the combination rule changes (section 3). + */ +export function matchesTopics(therapy: Therapy, topics: ReadonlySet): boolean { + if (topics.size === 0) return true; + const wanted = [...topics].map(lc); + return therapy.tags.some((tag) => wanted.includes(lc(tag))); +} + +/** + * Availability facet predicate — reviewed status and brief-intervention + * availability are independent constraints that AND together (they are two + * separate one-option facet groups, not options inside one group), and each + * ANDs against Topics in turn. Neither combines with the other by OR. + */ +export function matchesAvailability(therapy: Therapy, reviewedOnly: boolean, briefOnly: boolean): boolean { + if (reviewedOnly && therapy.reviewStatus !== "reviewed") return false; + if (briefOnly && !therapy.briefInterventionAvailable) return false; + return true; +} + function scoreTherapy(t: Therapy, q: string): number { if (!q) return 1; const name = lc(t.name); @@ -158,13 +188,12 @@ function scoreTherapy(t: Therapy, q: string): number { export function searchTherapies(therapies: Therapy[], opts: SearchOptions): Therapy[] { const q = opts.query.trim().toLowerCase(); - const wantTags = opts.tags.map(lc); + const topics = new Set(opts.tags); const scored = therapies .filter((t) => { - if (opts.briefOnly && !t.briefInterventionAvailable) return false; + if (!matchesAvailability(t, opts.reviewedOnly, opts.briefOnly)) return false; if (opts.sheetOnly && !t.patientSheetAvailable) return false; - if (opts.reviewedOnly && t.reviewStatus !== "reviewed") return false; - if (wantTags.length && !wantTags.every((wt) => t.tags.some((tag) => lc(tag) === wt))) return false; + if (!matchesTopics(t, topics)) return false; return scoreTherapy(t, q) > 0; }) .map((t) => ({ t, s: scoreTherapy(t, q) })); diff --git a/src/components/therapy-compass/filter-sheet.tsx b/src/components/therapy-compass/filter-sheet.tsx deleted file mode 100644 index b130960e11..0000000000 --- a/src/components/therapy-compass/filter-sheet.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { Sheet } from "@/components/ui/sheet"; - -import { outlineControl, softControl } from "./controls"; -import { CheckIcon, SlidersIcon, XIcon } from "./icons"; - -/** - * The phone filtering surface for therapy search. - * - * It replaces two native `