diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b087c87a17..25e18f4c64 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -883,7 +883,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-11 | codex/answer-loading-ui-20260811 | 6758f8156f9d1b3e893981dfd7a1f6563aa90da0 | answer creation loading UI | No high-confidence findings | UI 3 passed; unit 8 passed; lint, typecheck, build, design-system and offline RAG passed; full suite 6022 passed with 16 unchanged baseline failures | | 2026-08-11 | claude/codex-m4c-retire-shadow-nliak3 | 448a0d084c4cd2cda6153dd7f03dcb67c43a8df0 | DS Track A2 (#261): retire --shadow-focus; composer focus onto sanctioned outline; contract guard; baseline ratchet; design-system docs + ledger | Approved — PR #1807. Token deleted in both themes; .chat-composer-shell-delta:focus-within uses outline 2px var(--focus) at offset 2px and no longer overrides box-shadow. Reach premise corrected: 0 of 37 production routes render the class (only /mockups/calculators-search). legacyShadowAliases 127->125, globals.css pin 3->1. | check:design-system-contract PASS; design-token-contract.test.ts PASS + mutation-verified both ways; verify:pr-local PASS except pre-existing tests/pr-handoff-stop.test.ts failure baselined on untouched base e8b61d8; build PASS; check:rag:fixtures PASS (36 cases); Chromium look both themes on the mockup route (inspection only, rev 1194 vs pinned 1234 #255); verify:ui/verify:phone-chrome NOT run — delegated to CI | | 2026-08-11 | codex/answer-ecg-animation-20260811 | 12279a8309c225aa957ef1e65afc37545a0ce04c | answer ECG progress variants | No high-confidence findings; physical Safari/PWA remains residual acceptance | design contract, typecheck, focused unit 8/8, trace token 33/33, Chromium 4/4, offline RAG 574/574; full suite baseline/platform failures | -| 2026-08-11 | codex/chat-services-flow-redesign-20260812 | 6f44b92defb91bcd77509bf10337b428be37619c | Services home, results, shortlist, comparison, and referral detail redesign | No findings; changed-area UI, phone contracts, focused unit, build, and RAG fixtures passed; PR-local Windows baseline limitations documented. | 78 focused tests passed post-merge; 185 changed-browser tests; 129 phone contracts; 7 phone-scroll tests; build and RAG fixtures passed | +| 2026-08-11 | claude/filter-popup-design-mockups-x6sbjv | 7b64f2559741a9f353adcf939745831e0daff7db | services filter sheet redesign mockups (3 directions, desktop+phone) | PR #1828 opened; design-scratch route only, no production behaviour change | verify:pr-local (1 pre-existing root-uid test failure, reproduced on origin/main 046feb3), build, check:rag:fixtures, check:bundle-budget both baselines within tolerance, 320px 0px overflow | | 2026-08-11 | work | 6dcd695076d630d16aae594577763e8004361893 | Codex Cloud setup and local parity | P2 fixed: cache-friendly locked Cloud npm install; parity limitations documented | check:codex-cloud; codex-cloud-setup 24/24; full suite 6059 pass, 7 unrelated timeout/state failures | | 2026-08-11 | 1822 | 4fab267f52b72992745e1d2e6975fb4847af447a | review-and-fix | clean | Build pass; Static PR checks pass; Change scope pass; PR mergeability pass; PR policy pass; Safety and config checks pass; Semgrep pass; Semgrep ingestion gate pass; Gitleaks pass; GitGuardian pass; Unit coverage pending; Production UI (1) pass; Production UI (2) pass; Production UI critical pending; Production UI (3) pending; Lighthouse budget pass; PR required pending | | 2026-08-11 | 1822 | 4fab267f52b72992745e1d2e6975fb4847af447a | review-and-fix (supersedes 2026-08-11) | clean | Build pass; Static PR checks pass; Change scope pass; PR mergeability pass; PR policy pass; Safety and config checks pass; Semgrep pass; Semgrep ingestion gate pass; Gitleaks pass; GitGuardian pass; Unit coverage pass; Production UI (1) pass; Production UI (2) pass; Production UI (3) pass; Production UI critical pass; Lighthouse budget pass; PR required pass | @@ -891,6 +891,8 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-11 | 1821 | d76e90547dbdb104256b66a508c15c74302002fc | review-and-fix | dispositioned | PR policy:success; PR mergeability:success; Gitleaks:success; Semgrep:success; Semgrep ingestion gate:success; Safety and config checks:success; Build:success; Production UI critical:success; Production UI (1):success; Production UI (2):success; Production UI (3):failure test not reproduced outside this PR; PR required:failure | | 2026-08-11 | work | 45fd05c8c3947835c0368666ff576c7a38b33ee4 | mobile evidence sheet UX, accessibility, and feedback logic | fixed unexplained claim marker, excess panel reserve, unclear purpose and feedback copy; no remaining high-confidence defects | focused DOM 7/7; Chromium evidence journey 1/1; offline RAG 23 suites/574 tests | | 2026-08-11 | 1820 | 897ff11a4cdb13ae1c01f5eb149007847028f5aa | review-and-fix | fixed | Semgrep:IN_PROGRESS, Gitleaks:IN_PROGRESS, Semgrep ingestion gate:IN_PROGRESS, Static PR checks:QUEUED, Safety and config checks:QUEUED, Unit coverage:QUEUED, Build:QUEUED, Production UI critical:QUEUED, Lighthouse budget:QUEUED | +| 2026-08-12 | claude/filter-popup-design-mockups-x6sbjv | 5e41d164e30e8f5a74b255fbeafd34123385dbb9 | services filter: round-two options study (stop-the-bleed / recommended / presets-evicted) | Pushed to PR #1828; merged babysit fixes to round-one facet semantics; design-scratch only | verify:pr-local (1 pre-existing root-uid failure only), build, check:rag:fixtures, bundle-budget mockups 286.8 KiB within 25% tolerance, counts re-verified vs snapshot, 320px 0px overflow | +| 2026-08-11 | codex/chat-services-flow-redesign-20260812 | 6f44b92defb91bcd77509bf10337b428be37619c | Services home, results, shortlist, comparison, and referral detail redesign | No findings; changed-area UI, phone contracts, focused unit, build, and RAG fixtures passed; PR-local Windows baseline limitations documented. | 78 focused tests passed post-merge; 185 changed-browser tests; 129 phone contracts; 7 phone-scroll tests; build and RAG fixtures passed | | 2026-08-11 | claude/spacing-icon-design-review-rxwh28 | f8701a524f0eb22decc64ce1f626bdafe91751af | mode-home hero spacing rhythm + icon scale (PR #1815) | shipped: group copy reserve banded to measured wrap points, continuous hero medallion, phone composer glyph 1.1rem->icon-lg, surface glyphs onto size-icon-*, privacy link bottom-only negative margin (fixes 8px tap overhang on the APP-5 sentence), composer phone reserve 7.625->6.625rem | test 6043 passed/1 pre-existing root-perm failure; lint; typecheck; build; check:icon-scale; check:type-scale; check:design-system-contract; check:rag:fixtures; check:bundle-budget; prettier --check .; verify:ui NOT run (Playwright r1234 vs image r1194, #255 - delegate to CI Production UI) | | 2026-08-11 | 1815 | be7461ef1f66357999995acefbeecaf95268e481 | unblock | local-build-pass | MergeTreeClean,UnitCoverage,StaticPRChecks,ContainerImages | | 2026-08-11 | claude/spacing-icon-design-review-rxwh28 | 455bc198c077860fb1f830670a5fa9c1de08da52 | pr-1815 heavy review-and-fix | remote already merged main (shadow-tight Switch kept); cherry-picked privacy -mb-4 reclaim + calculators dock cancel; removed duplicate UniversalSearchAlsoMatches; rail-aware section-sheet focus restore; dispositioned CodeRabbit docs/ledger/gates nits and outdated Sentry skeleton gap | verify:cheap PASS prior tip; verify:pr-local PASS prior tip; vitest privacy+in-page-nav 28 passed on cherry-pick; merge-tree clean vs origin/main | diff --git a/docs/site-map.md b/docs/site-map.md index 4a7df112bc..8a332e752b 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1030,6 +1030,8 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/search-heading` - Route discovered from app directory Source: `src/app/mockups/search-heading/page.tsx`. - `/mockups/search-lens-menu` - Route discovered from app directory Source: `src/app/mockups/search-lens-menu/page.tsx`. - `/mockups/search-refine-adaptive` - Route discovered from app directory Source: `src/app/mockups/search-refine-adaptive/page.tsx`. +- `/mockups/services-filter-options` - Route discovered from app directory Source: `src/app/mockups/services-filter-options/page.tsx`. +- `/mockups/services-filter-refined` - Route discovered from app directory Source: `src/app/mockups/services-filter-refined/page.tsx`. - `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. - `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. diff --git a/mockups/README.md b/mockups/README.md index ea223959a4..a06679be58 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -74,3 +74,56 @@ Three sticky header directions for record pages that use `InformationPageBreadcr **Outcome: direction 02 shipped**, as the breadcrumb shape of the existing `InPageNavHeader` rather than a new component — omitting `sections` drops the disclosure and the track, and `primaryAction` / `mode` / `showBackLabel` shape the row. Adopted first on `/factsheets/`, where the reading level rides the `mode` slot. Contract: `docs/search-chrome-behaviour.md` ("The breadcrumb shape"). **The runnable `/mockups/breadcrumb-header` route was removed once 02 shipped.** It was not deleted for tidiness: `check:bundle-budget` totals every built chunk, mockups included, and `main` sits at roughly +9.4% against a 10% tolerance, so the study's two scratch chunks (~9.8 KiB gzip) alone pushed the repo to +10.1% and failed `Build` — the same failure PR #1580 hit, at the same number. A design-scratch route that 404s in production is the wrong thing to spend the last of that headroom on. The table above is the durable record; recover the route from history if the alternatives need re-reading. + +## Services filter surface (2026-08-11) + +Runnable study at [`/mockups/services-filter-refined`](../src/app/mockups/services-filter-refined/page.tsx): three +directions for the sheet reached from the **Filter** control in the services results band, each shown at desktop and +phone, plus a reproduction of what ships today with its defects annotated. + +The finding that drove the study is not cosmetic. The sheet is titled "Filter services" and nothing in it filters — +every chip calls `applyServiceQuery()`, which pushes a new route and **replaces the query**, so choosing "Crisis" while +reading "16 services · lithium level timing" discards that search and its results. It is a preset switcher wearing a +funnel icon. Secondary defects: no per-option counts, four unrelated categories in one flat chip row, a dead band below +the fold, "Done" as a low-emphasis outlined button doing the primary job, and a phone `role="radiogroup"` contradicting +the desktop rail's `aria-pressed` toggles. + +| Direction | Shape | Blast radius | +| ------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------- | +| A — Refine in place | Facet groups + separated "Start a new search" block | Fits current `ResultFilterSheet` props; upgrades all 7 modes, nothing forks | +| B — Presets/filters split | Two tabs, plus a persistent active-filter row under the band | Tab contract in the shared sheet + a band row other modes inherit | +| C — Directory-grade | Persistent desktop rail; phone find-a-filter + collapse | New services desktop layout + a services facet index; services-only | + +Every count in the study is real, computed live from the 219 services in `data/services-snapshot.json` via ~1KB of +base64 facet bitmasks (OR within a group, AND across groups). The tags are already populated and unused by the UI: +acuity, catchments, age groups, setting flags, substance and housing flags, plus `confidence`. + +Three data caveats recorded in the study itself: a **"No cost" facet is not free** (`cost_funding` is 87 distinct +free-text values; ~69 of 219 match a free-ish pattern, so it is deliberately absent rather than faked); +`age_groups: mixed` (202/219) and `setting_flags: public` (207/219) are **omitted as facets** because an option that +never excludes anything is a row of dead pixels; and the radiogroup/`aria-pressed` disagreement must be resolved +deliberately rather than inherited. + +### Round two — three options along the recommended path (2026-08-11) + +Runnable study at [`/mockups/services-filter-options`](../src/app/mockups/services-filter-options/page.tsx). +Round one offered three _directions_; asked which to build, the answer was a sequence rather than a +winner, plus one bolder move flagged as a product judgement. This study draws those three threads so +they can be compared directly. + +| Option | Verdict | What it is | +| ---------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 — Stop the bleed | Ship first | No filtering. Renames the sheet, shows each shortcut's literal query, and puts the current search at the top as the thing you are about to lose. No new state, no new component | +| 2 — The recommendation | Recommended | Direction A's facets + counts + committed `Show N services`, plus direction B's persistent active-filter pill row. Phone shows the sheet open, desktop shows it closed — the closed state is what the pill row is for | +| 3 — Presets evicted | Your call | Sheet becomes purely a filter; the six presets move to the composer as `AnswerSuggestionChips`, a production component whose own prop docs name composer rows as a use. Deletes sheet code rather than adding a surface | + +Three points the study makes that are easy to lose: the expensive part (services facet index, +selection state, URL round-tripping) is **identical in options 2 and 3**, so choosing between them on +build cost is a false economy; **counts and multi-select have to ship together**, because a count on +a single-select radio only reports the size of the thing you are about to jump to; and option 1 is +**purely subtractive**, so it can land while the facet work is still being scoped and nothing in it +has to be unpicked afterwards. + +The facet engine, chips, band and sheet shell are imported from the round-one study rather than +copied — the ~1KB bitmask table would otherwise be duplicated against a finite `mockups` bundle +budget, and two studies quoting different numbers for the same catalogue would discredit both. diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 4a2c9d166f..6a2af37cd5 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -29,6 +29,14 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { // Draws its own result bands inside device frames; the shared chrome above // them would read as a third, real band competing with the two on offer. const isSearchBandDirectionsMockup = pathname === "/mockups/search-band-directions"; + // Draws its own results band and an in-frame filter sheet inside every device + // frame; the shared composer would read as a second, real search bar over a + // study whose whole subject is the control that sits in that band. + const isServicesFilterRefinedMockup = pathname === "/mockups/services-filter-refined"; + // Option 3 in this study draws its own services composer with suggestion chips + // beside it — that is the concept under judgement, so the shared composer + // above it would be a second, real search bar competing with the proposal. + const isServicesFilterOptionsMockup = pathname === "/mockups/services-filter-options"; const isPhoneInPageNavigationMockup = pathname === "/mockups/phone-inpage-navigation"; // Draws its own composer in every frame, and the notice under study is the one // the shared composer renders — showing both would put two different privacy @@ -84,6 +92,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isCalculatorsSearchPageMockup && !isPhoneInPageNavigationMockup && !isSearchBandDirectionsMockup && + !isServicesFilterRefinedMockup && + !isServicesFilterOptionsMockup && !isTherapyNavigationMockup && !isWarningConsolidationMockup && !isWarningLineMockup && @@ -99,6 +109,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isSearchHeadingMockup && !isSearchRefineAdaptiveMockup && !isSearchBandDirectionsMockup && + !isServicesFilterRefinedMockup && + !isServicesFilterOptionsMockup && !isPhoneInPageNavigationMockup && !isTherapyNavigationMockup && !isWarningConsolidationMockup && diff --git a/src/app/mockups/services-filter-options/page.tsx b/src/app/mockups/services-filter-options/page.tsx new file mode 100644 index 0000000000..6a1f76e66e --- /dev/null +++ b/src/app/mockups/services-filter-options/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { ServicesFilterOptionsMockupsPage } from "@/components/services-filter-options-mockups"; + +export const metadata: Metadata = { + title: "Services filter · three options - Clinical KB", + description: + "Round two of the services filter study: the fix that can ship now, the recommended build, and the bolder move that evicts presets from the sheet — at desktop and phone.", +}; + +export default function ServicesFilterOptionsMockupPage() { + return ; +} diff --git a/src/app/mockups/services-filter-refined/page.tsx b/src/app/mockups/services-filter-refined/page.tsx new file mode 100644 index 0000000000..95c38dc3fb --- /dev/null +++ b/src/app/mockups/services-filter-refined/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { ServicesFilterRefinedMockupsPage } from "@/components/services-filter-refined-mockups"; + +export const metadata: Metadata = { + title: "Services filter · three directions - Clinical KB", + description: + "Three redesign directions for the services filter sheet, at desktop and phone, with live facet counts from the 219-service catalogue snapshot.", +}; + +export default function ServicesFilterRefinedMockupPage() { + return ; +} diff --git a/src/components/services-filter-options-mockups.tsx b/src/components/services-filter-options-mockups.tsx new file mode 100644 index 0000000000..bdab43901c --- /dev/null +++ b/src/components/services-filter-options-mockups.tsx @@ -0,0 +1,796 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { ArrowUpRight, Check, CornerDownLeft, Search, Sparkles, X } from "lucide-react"; + +import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; +import { cn } from "@/components/ui-primitives"; +import { + ActiveFilterPill, + CommitButton, + FACET_GROUPS, + FacetGroupsBody, + focusRing, + PhoneSheetShell, + PRESETS, + ResultsBand, + ResultsPreview, + SERVICE_TOTAL, + useFacetCounts, +} from "@/components/services-filter-refined-mockups"; + +/** + * Three options along the recommended path for the services filter surface. + * + * This is the follow-up to `/mockups/services-filter-refined`, and it is NOT a + * re-run of directions A/B/C. Asked which of those three to build, the answer + * was a sequence rather than a winner, plus one bolder possibility flagged as a + * product judgement rather than a design one. These are those three threads, + * drawn so they can be compared directly: + * + * 1 · Stop the bleed — no filtering at all. The only option that could ship + * this week, because it needs no new state. It exists to end the silent + * data loss: today a preset replaces your query with no warning and no + * undo, and you only discover it after the results have gone. + * 2 · The recommendation — direction A's facet structure plus direction B's + * persistent active-filter row. What the previous study concluded should + * actually be built. + * 3 · Presets evicted — the sheet becomes purely a filter and the six presets + * relocate to the composer as suggested searches. Removes the two-verbs + * problem at the root instead of signposting it, and pays for that in + * discoverability. + * + * The facet engine, chips, band and sheet shell are imported from the first + * study rather than copied. That is deliberate on two counts: the ~1KB bitmask + * table would otherwise be duplicated against a finite `mockups` bundle budget, + * and two studies quoting different numbers for the same catalogue would + * discredit both. Mockup-to-mockup imports are permitted; only production + * source is barred from importing `*-mockups`. + * + * Sizing note: these render a 390px phone frame inside a wide page, so viewport + * `sm:` variants would resolve against the page rather than the frame. Every + * component takes an explicit `compact` flag instead. + */ + +/* ------------------------------------------------------------------------- */ +/* Option 1 — Stop the bleed */ +/* ------------------------------------------------------------------------- */ + +const CURRENT_QUERY = "lithium level timing"; +const CURRENT_COUNT = 16; + +/** The exact query string each preset writes, quoted from `serviceQuickFilters`. */ +const PRESET_QUERIES: Record = { + "best-fit": "13YARN crisis Aboriginal Torres Strait Islander phone", + crisis: "crisis", + "culturally-safe": "Aboriginal Torres Strait Islander", + phone: "phone referral", + free: "free", + wa: "WA", +}; + +function ShortcutRow({ preset, compact }: { preset: (typeof PRESETS)[number]; compact: boolean }) { + return ( + + ); +} + +/** The current search, shown as the thing you would lose. */ +function CurrentSearchRow({ compact }: { compact: boolean }) { + return ( +
+ + + + Staying on “{CURRENT_QUERY}” + + + {CURRENT_COUNT} services · anything below replaces this + + +
+ ); +} + +function OptionOne({ compact }: { compact: boolean }) { + const shortcuts = ( + <> + +
+ +
+ Run a different search +
+
+
+ {PRESETS.map((preset) => ( + + ))} +
+ + ); + + if (compact) { + return ( +
+ {}} + variant="trigger" + /> + + { + /* Comp only — dismisses without touching the query. */ + }} + className={cn( + "inline-flex min-h-tap w-full items-center justify-center gap-1.5 rounded-lg bg-[color:var(--clinical-accent)] px-3 text-xs font-extrabold text-white", + focusRing, + )} + > + + Keep “{CURRENT_QUERY}” + + } + > + {shortcuts} + +
+ ); + } + + return ( +
+
+
+ + + {CURRENT_COUNT} + {" "} + services + · {CURRENT_QUERY} + +
+ {/* The shipped desktop rail keeps its shape; only the labelling becomes + honest. "Quick filters" implied these narrow the 16 above. */} +
+ + + Runs a new search + +
+ {PRESETS.map((preset) => ( + + ))} +
+
+
+ +
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Option 2 — The recommendation (A's facets + B's persistent pill row) */ +/* ------------------------------------------------------------------------- */ + +function OptionTwo({ + compact, + selected, + onToggle, + onClear, +}: { + compact: boolean; + selected: ReadonlySet; + onToggle: (facetId: string) => void; + onClear: () => void; +}) { + const counts = useFacetCounts(selected); + const ordered = FACET_GROUPS.flatMap((group) => group.facets.filter((facet) => selected.has(facet.id))); + + // Phone shows the sheet OPEN — the facets, counts and commit. + if (compact) { + return ( +
+ {}} + variant="trigger" + /> + + 0 ? onClear : undefined} + footer={} + > + +
+
+ +
+ Start a new search +
+
+
+ {PRESETS.slice(0, 3).map((preset) => ( + + ))} +
+ +
+ ); + } + + // Desktop shows the sheet CLOSED, which is the half B is arguing for: the + // refinement stays legible with nothing open. + return ( +
+ {}} + variant="rail" + > + {selected.size > 0 ? ( +
+ + Filtered by + +
+ {ordered.map((facet) => ( + onToggle(facet.id)} + /> + ))} +
+ +
+ ) : null} +
+ +

+ Sheet closed. Without the pill row above, the only trace of{" "} + {selected.size || "any"} active{" "} + {selected.size === 1 ? "filter" : "filters"} would be a badge count on the trigger. +

+
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Option 3 — Presets evicted */ +/* ------------------------------------------------------------------------- */ + +/** + * A services composer with the six presets living beside it as suggestions. + * + * The chips here are deliberately `AnswerSuggestionChips` — a production + * component (`clinical-dashboard/answer-suggestion-chips.tsx`) whose own prop + * docs describe `labelPlacement="inline"` as being for "composer rows, empty + * state". So this option needs no new component: the presets stop being fake + * filters and become what they always were, suggested searches, in the surface + * that already exists for suggested searches. + */ +function ComposerWithSuggestions({ compact }: { compact: boolean }) { + return ( +
+
+ + + Search services… + + + Search + +
+ preset.label)} + onPick={() => { + /* Comp only — the real chip writes the query into the composer. */ + }} + /> +
+ ); +} + +function OptionThree({ + compact, + selected, + onToggle, + onClear, +}: { + compact: boolean; + selected: ReadonlySet; + onToggle: (facetId: string) => void; + onClear: () => void; +}) { + const counts = useFacetCounts(selected); + + if (compact) { + return ( +
+ +
+ {}} + variant="trigger" + /> +
+ {/* The sheet with no preset block at all — visibly shorter, and every + control in it does the same kind of thing. */} + 0 ? onClear : undefined} + footer={} + > + + +
+ ); + } + + return ( +
+ +
+
+ {}} + variant="trigger" + /> + +
+
+
+
+

Filter services

+

+ {counts.total} of {SERVICE_TOTAL} match +

+
+ {selected.size > 0 ? ( + + ) : null} +
+
+ +
+
+ +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Study frame */ +/* ------------------------------------------------------------------------- */ + +function PreviewFrame({ + label, + phone = false, + children, +}: { + label: string; + phone?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + + {phone ? "390 px" : "1440 px"} +
+
+
{children}
+
+
+ ); +} + +function OptionSection({ + id, + eyebrow, + title, + lede, + verdict, + verdictTone, + cost, + strengths, + desktop, + desktopLabel = "Desktop", + phone, +}: { + id: string; + eyebrow: string; + title: string; + lede: string; + verdict: string; + verdictTone: "now" | "build" | "judgement"; + cost: string; + strengths: ReadonlyArray; + desktop: React.ReactNode; + desktopLabel?: string; + phone: React.ReactNode; +}) { + const toneClass = + verdictTone === "build" + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" + : verdictTone === "now" + ? "border-[color:var(--success)]/35 bg-[color:var(--success)]/10 text-[color:var(--success)]" + : "border-[color:var(--warning)]/35 bg-[color:var(--warning)]/10 text-[color:var(--warning)]"; + return ( +
+
+
+
+ + {eyebrow} + + + {verdict} + +
+

+ {title} +

+

{lede}

+

+ Cost · + {cost} +

+
+
+ {strengths.map((strength) => ( + + {strength} + + ))} +
+
+
+ {desktop} + + {phone} + +
+
+ ); +} + +const anatomy: ReadonlyArray<{ term: string; detail: string }> = [ + { + term: "The three are a sequence, not a menu", + detail: + "1 can ship on its own and is compatible with both others. 2 supersedes 1's sheet but keeps its honesty about presets. 3 is 2 with the presets moved out, so it is a decision to take after 2 exists, not instead of it.", + }, + { + term: "The expensive part is identical in 2 and 3", + detail: + "Services has no filter state today — the chip is the query. The facet index, selection state and URL round-tripping are the bulk of the work and are the same code either way. Only the presentation differs, so picking on build cost between 2 and 3 is a false economy.", + }, + { + term: "Counts and multi-select ship together", + detail: + "A count on a single-select radio only reports the size of the thing you are about to jump to, never what you are narrowing. Shipping counts on top of today's radiogroup would look like progress and mean nothing.", + }, + { + term: "Option 3 reuses a production component", + detail: + "The evicted presets become AnswerSuggestionChips, whose own prop documentation names composer rows and empty states as its use. So option 3 deletes sheet code rather than adding a surface — its risk is product, not engineering.", + }, + { + term: "Option 1 is the only one that is purely subtractive", + detail: + "It adds no state, no facets and no new component. It renames, reorders, and shows the query you would lose. That is why it can land while the facet work is still being scoped.", + }, + { + term: "Every count here is real", + detail: + "Imported from the first study's facet index rather than re-declared, so the two pages cannot drift to different numbers for the same 219-service catalogue.", + }, +]; + +export function ServicesFilterOptionsMockupsPage() { + const [selected, setSelected] = useState>(() => new Set(["acuity:crisis_high"])); + + const toggleFacet = useCallback((facetId: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(facetId)) next.delete(facetId); + else next.add(facetId); + return next; + }); + }, []); + + const clearAll = useCallback(() => setSelected(new Set()), []); + const counts = useFacetCounts(selected); + + return ( +
+
+
+
+ + Services · filter surface · round two + +

+ Three options along the recommended path +

+

+ The first study offered three directions. This one offers the three decisions that came + out of choosing between them: the fix that can ship immediately, the one recommended for the real build, + and the bolder move that is a product judgement rather than a design one. +

+

+ Counts are live and imported from{" "} + services-filter-refined-mockups, so both studies quote the + same {SERVICE_TOTAL}-service catalogue. Options 2 and 3 share selection state — a facet chosen in one + updates the other. +

+
+
+ + + {selected.size === 0 + ? `No filters applied — all ${SERVICE_TOTAL} catalogue services.` + : `${selected.size} ${selected.size === 1 ? "filter" : "filters"} applied — ${counts.total} of ${SERVICE_TOTAL} match.`} + + {selected.size > 0 ? ( + + ) : null} +
+
+
+ +
+ } + phone={} + /> + + } + desktopLabel="Desktop · sheet closed" + phone={} + /> + + } + desktopLabel="Desktop · composer + filter" + phone={} + /> + +
+
+

+ How they relate +

+

+ What choosing one commits you to, and what it does not. +

+
+
+ {anatomy.map((entry, index) => ( +
0 && "border-t", + index % 2 === 1 && "sm:border-l", + index === 1 && "sm:border-t-0", + )} + > +
{entry.term}
+
{entry.detail}
+
+ ))} +
+
+ +
+

+ Before this becomes production +

+
    +
  1. + Option 1 has one open question. Showing + the current query as a row implies it is selectable. Here it is a static state, not a control — if it + becomes tappable it needs to be a real radio, which pulls back in the single-select contract options 2 and + 3 replace. +
  2. +
  3. + + Options 2 and 3 need filter state that does not exist. + {" "} + Services has none today — the chip is the query. Build the index on the model of{" "} + buildSmartDocumentTagFacetIndex in{" "} + src/lib/document-tags.ts. +
  4. +
  5. + + Option 3 needs a discoverability answer, not a design one. + {" "} + Moving presets to the composer is cheap to build and easy to revert; the question is whether anyone finds + them there. That is worth a look at whether the presets are used at all before committing either way. +
  6. +
  7. + The band changes touch phone chrome.{" "} + Option 2’s pill row and option 3’s composer both add height near the reserve — read{" "} + docs/search-chrome-behaviour.md and run{" "} + npm run verify:phone-chrome. +
  8. +
  9. + Mockups skip two gates, not all of them.{" "} + Every control here is already at min-h-tap (48px) on phone — do + not relax that to 44px for generic a11y guidance, which reintroduces a known{" "} + ui-smoke flake. +
  10. +
+
+
+
+ ); +} diff --git a/src/components/services-filter-refined-mockups.tsx b/src/components/services-filter-refined-mockups.tsx new file mode 100644 index 0000000000..25bf8b119e --- /dev/null +++ b/src/components/services-filter-refined-mockups.tsx @@ -0,0 +1,1733 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { + ArrowUpRight, + Check, + ChevronDown, + Funnel, + ListFilter, + RotateCcw, + Search, + SlidersHorizontal, + X, +} from "lucide-react"; + +import { cn } from "@/components/ui-primitives"; + +/** + * Three directions for the services filter surface — the sheet reached from the + * "Filter" control in the services results band. + * + * The thesis in one line: the shipped sheet is titled "Filter services" but + * nothing in it filters. Every chip calls `applyServiceQuery(...)`, which pushes + * a new route and REPLACES the query, so choosing "Crisis" while reading + * "16 services · lithium level timing" discards that search. It is a preset + * switcher wearing a funnel icon. All three directions below start by separating + * the two verbs — narrow these results vs. start a different search — and differ + * only in how far they take the second half. + * + * Every count on this page is real. `data/services-snapshot.json` already carries + * populated `CatalogServiceTags` for all 219 services; the facet masks below are + * generated from it, and the counts you see recompute live as facets are + * selected (OR within a group, AND across groups). Nothing here is a placeholder + * number, which matters because "no per-chip counts" is one of the defects being + * fixed — a comp that fakes them would be arguing its own case. + * + * Deliberate departures from the shipped sheet, common to all three: + * - Presets are visually separated from filters and marked as navigational. + * They leave the current search; filters do not. + * - The footer's passive "16 showing" becomes the primary commit — "Show 41 + * services" — so the button states its own outcome. + * - Counts on every facet, so a choice is never blind and a zero-yield facet + * is legible before it is tapped. + * - One selection contract across breakpoints. Today phone is a + * `role="radiogroup"` (single-select) while desktop is a rail of + * `aria-pressed` toggles (multi-select) — the same six filters under two + * contradictory promises. + * + * Sizing note: these studies render a 390px phone frame inside a wide page, so + * viewport `sm:` variants would resolve against the page rather than the frame. + * Every component here takes an explicit `compact` flag instead — `compact` + * mirrors the production `min-h-tap` (48px) phone floor, and the wide variant + * mirrors `sm:min-h-9`/`min-h-10`. + */ + +/* ------------------------------------------------------------------------- */ +/* Facet index — generated from data/services-snapshot.json (219 services) */ +/* ------------------------------------------------------------------------- */ + +export const SERVICE_TOTAL = 219; + +/** + * One base64 bitmask per facet, 219 bits wide, index-aligned to the snapshot's + * service order. ~1KB total, which is what makes live recomputation affordable + * inside a mockup that still has to answer to `check:bundle-budget`. + */ +const FACET_MASKS: Record = { + "acuity:crisis_high": "gYAUgA88gAKABMMMPxQCAABFAHAIAAAQAUgAAg==", + "acuity:high": "8KcQ084ePBCBhJCUOUMDA4NuCmnICv0blRhHAw==", + "acuity:moderate": "9qc/qftbv81j9Brs/+9Xw+dvv+nsuxOJtzv3Aw==", + "acuity:supportive": "//vf/3v9/Gb+v///f3W//5//zB9fzv//2fz9Bg==", + "catchment:Metro-wide": "1/qP/X384AXs/0r/X2Q//p7HzB8dwPv9n/x9Ag==", + "catchment:Regional WA": "gwABQwAQYP6Hg7wAEDwAAALoOIBIfL4D4ScABA==", + "catchment:SMHS/Fremantle": "OAAAQQABHwAQggAQoAACEAQcAwCiAgQCAACDAQ==", + "catchment:NMHS/North Metro": "QCACQQAAAAEAAAAggEDA58ACAAAwAEUIEYBlAA==", + "catchment:EMHS City East": "AA8wA4oGAAAAQAEgiIMBACMAAGAAAQACABCBAQ==", + "catchment:Peel": "AABAAAAAAAAAggAAqAAAIACYAwAAAgQCAAAAAA==", + "catchment:Armadale/Kalamunda": "AAEAQAAAAAAAAAAggAAAAAAAAAAgAQACAAAAAA==", + "age:youth": "AMINgMFUAAEhAAKQgBBDwh1AxAAhAAEAAer/Bw==", + "age:young_adult": "AAIAAIAEAAEAAAIAAAABAgAAxAAgAAAAAIBfBw==", + "age:adult": "cGc6AAADP0QDoDgATHVCnQMfKwAC2OkIkhYhAA==", + "age:older_adult": "AAYwgAAAGUQDADgAADUCQQAICgAAWAAAgBYAAA==", + "setting:community": "dH/2/5oDP4r29RR+cUvAf4Ar0zIW5O0ttZPtBw==", + "setting:hospital_residential": "8G/2/9uXP97f99z6cUvDf44v23uT7P3/v5vvBQ==", + "setting:digital_phone": "u5BTn0ubzHDr99YUW5PqaQrVIFkIEBJSCBrHBw==", + "focus:aod": "kAIBOVRMoIqkQTSDkBoLAHPg8OTgJxCgIhEYAg==", + "focus:housing_support": "wBxQxkiwoBAaP0GbJIIJAJ6ggAmFwPzLCchUAA==", + "focus:home_based": "YAAAAAIAAAAAAAAAIAAAAQAAAAAAAAAAAQAGAA==", + "confidence:High": "tq8/7Vv2n4+8vo65/W1fw+vf3/87b/2rt739Aw==", + "confidence:Medium": "QVDAEqQJIGBDQXFGApKgPBQgIADEkAIUSEICBA==", + "confidence:Low": "CAAAAAAAQBAAAAAAAAAAAAAAAAAAAABAAAAAAA==", +}; + +export type FacetGroupId = "acuity" | "catchment" | "age" | "setting" | "focus" | "confidence"; + +type FacetGroup = { + id: FacetGroupId; + label: string; + /** Shown under the group heading in the widest direction only. */ + hint: string; + facets: ReadonlyArray<{ id: string; label: string }>; +}; + +export const FACET_GROUPS: ReadonlyArray = [ + { + id: "acuity", + label: "Acuity", + hint: "What the service is equipped to hold.", + facets: [ + { id: "acuity:crisis_high", label: "Crisis" }, + { id: "acuity:high", label: "High acuity" }, + { id: "acuity:moderate", label: "Moderate" }, + { id: "acuity:supportive", label: "Supportive" }, + ], + }, + { + id: "catchment", + label: "Catchment", + hint: "Where the patient has to live.", + facets: [ + { id: "catchment:Metro-wide", label: "Metro-wide" }, + { id: "catchment:Regional WA", label: "Regional WA" }, + { id: "catchment:SMHS/Fremantle", label: "SMHS / Fremantle" }, + { id: "catchment:NMHS/North Metro", label: "NMHS / North Metro" }, + { id: "catchment:EMHS City East", label: "EMHS / City East" }, + { id: "catchment:Peel", label: "Peel" }, + { id: "catchment:Armadale/Kalamunda", label: "Armadale / Kalamunda" }, + ], + }, + { + id: "age", + label: "Age group", + hint: "Stated age scope, beyond the near-universal “mixed”.", + facets: [ + { id: "age:youth", label: "Youth" }, + { id: "age:young_adult", label: "Young adult" }, + { id: "age:adult", label: "Adult" }, + { id: "age:older_adult", label: "Older adult" }, + ], + }, + { + id: "setting", + label: "Setting", + hint: "How contact actually happens.", + facets: [ + { id: "setting:community", label: "Community" }, + { id: "setting:hospital_residential", label: "Hospital / residential" }, + { id: "setting:digital_phone", label: "Digital & phone" }, + ], + }, + { + id: "focus", + label: "Specialist focus", + hint: "Co-occurring need the service names explicitly.", + facets: [ + { id: "focus:aod", label: "Alcohol & other drugs" }, + { id: "focus:housing_support", label: "Housing support" }, + { id: "focus:home_based", label: "Home-based" }, + ], + }, + { + id: "confidence", + label: "Record confidence", + hint: "How well verified this entry is.", + facets: [ + { id: "confidence:High", label: "High" }, + { id: "confidence:Medium", label: "Medium" }, + { id: "confidence:Low", label: "Low" }, + ], + }, +]; + +const GROUP_OF_FACET = new Map( + FACET_GROUPS.flatMap((group) => group.facets.map((facet) => [facet.id, group.id] as const)), +); + +const FACET_LABEL = new Map( + FACET_GROUPS.flatMap((group) => group.facets.map((facet) => [facet.id, facet.label] as const)), +); + +const MASK_LENGTH = Math.ceil(SERVICE_TOTAL / 8); + +/** TypeScript 6 distinguishes the backing buffer, so the alias keeps every helper in agreement. */ +type Mask = Uint8Array; + +function decodeMask(encoded: string): Mask { + const binary = atob(encoded); + if (binary.length !== MASK_LENGTH) { + throw new Error(`Service facet mask length mismatch: expected ${MASK_LENGTH} bytes, got ${binary.length}`); + } + const bytes = new Uint8Array(MASK_LENGTH); + for (let index = 0; index < MASK_LENGTH; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +const DECODED: Record = Object.fromEntries( + Object.entries(FACET_MASKS).map(([key, value]) => [key, decodeMask(value)]), +); + +/** All 219 bits set — the "no filters" starting universe. */ +const UNIVERSE = (() => { + const bytes = new Uint8Array(MASK_LENGTH).fill(0xff); + const overflow = MASK_LENGTH * 8 - SERVICE_TOTAL; + if (overflow > 0) bytes[MASK_LENGTH - 1] &= 0xff >> overflow; + return bytes; +})(); + +function and(left: Mask, right: Mask): Mask { + const out = new Uint8Array(MASK_LENGTH); + for (let index = 0; index < MASK_LENGTH; index += 1) out[index] = left[index] & right[index]; + return out; +} + +function or(left: Mask, right: Mask): Mask { + const out = new Uint8Array(MASK_LENGTH); + for (let index = 0; index < MASK_LENGTH; index += 1) out[index] = left[index] | right[index]; + return out; +} + +function popcount(mask: Mask): number { + let total = 0; + for (let index = 0; index < MASK_LENGTH; index += 1) { + let byte = mask[index]; + while (byte) { + byte &= byte - 1; + total += 1; + } + } + return total; +} + +/** OR within a group, AND across groups — the standard facet contract. */ +function maskForSelection(selected: ReadonlySet, skipGroup?: FacetGroupId): Mask { + let result: Mask = UNIVERSE; + for (const group of FACET_GROUPS) { + if (group.id === skipGroup) continue; + const chosen = group.facets.filter((facet) => selected.has(facet.id)); + if (chosen.length === 0) continue; + let union: Mask = new Uint8Array(MASK_LENGTH); + for (const facet of chosen) union = or(union, DECODED[facet.id]); + result = and(result, union); + } + return result; +} + +export type FacetCounts = { total: number; perFacet: Record }; + +export function useFacetCounts(selected: ReadonlySet): FacetCounts { + return useMemo(() => { + const total = popcount(maskForSelection(selected)); + const perFacet: Record = {}; + for (const group of FACET_GROUPS) { + for (const facet of group.facets) { + const candidate = new Set(selected); + candidate.add(facet.id); + perFacet[facet.id] = popcount(maskForSelection(candidate)); + } + } + return { total, perFacet }; + }, [selected]); +} + +/* ------------------------------------------------------------------------- */ +/* Presets — the six that ship today, unchanged in meaning */ +/* ------------------------------------------------------------------------- */ + +export const PRESETS: ReadonlyArray<{ id: string; label: string; detail: string }> = [ + { id: "best-fit", label: "Best fit", detail: "13YARN crisis · culturally safe · phone" }, + { id: "crisis", label: "Crisis", detail: "Searches “crisis”" }, + { id: "culturally-safe", label: "Culturally safe", detail: "Aboriginal & Torres Strait Islander" }, + { id: "phone", label: "Phone referral", detail: "Searches “phone referral”" }, + { id: "free", label: "Free", detail: "Searches “free”" }, + { id: "wa", label: "WA", detail: "Searches “WA”" }, +]; + +/** Real records from the snapshot, so the frames are not populated with invented services. */ +const SAMPLE_RESULTS: ReadonlyArray<{ name: string; meta: string; confidence: string }> = [ + { name: "Crisis Care", meta: "Metro-wide · Community, phone", confidence: "High" }, + { name: "CAMHS Crisis Connect", meta: "Metro-wide · Digital & phone", confidence: "High" }, + { name: "Alcohol and Drug Support Line", meta: "Regional WA · Digital & phone", confidence: "High" }, + { name: "13YARN", meta: "Regional WA, Metro-wide · Phone", confidence: "Medium" }, +]; + +/* ------------------------------------------------------------------------- */ +/* Shared control primitives */ +/* ------------------------------------------------------------------------- */ + +export const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +/** The tap floor: 48px on phone per the repo's `--spacing-tap`, 36px where a pointer is likely. */ +function tapHeight(compact: boolean) { + return compact ? "min-h-tap" : "min-h-9"; +} + +export function FacetChip({ + label, + count, + selected, + compact, + onToggle, +}: { + label: string; + count: number; + selected: boolean; + compact: boolean; + onToggle: () => void; +}) { + const empty = count === 0 && !selected; + return ( + + ); +} + +export function ActiveFilterPill({ + facetId, + onRemove, + compact, +}: { + facetId: string; + onRemove: () => void; + compact: boolean; +}) { + return ( + + {FACET_LABEL.get(facetId)} + + + ); +} + +export function PresetRow({ + preset, + compact, + onRun, +}: { + preset: (typeof PRESETS)[number]; + compact: boolean; + onRun: () => void; +}) { + return ( + + ); +} + +/** The band the sheet is opened from — reproduced so each frame has real context. */ +export function ResultsBand({ + compact, + count, + query, + activeCount, + onOpen, + variant, + children, +}: { + compact: boolean; + count: number; + query: string; + activeCount: number; + onOpen: () => void; + variant: "trigger" | "rail"; + children?: React.ReactNode; +}) { + return ( +
+
+ + {count}{" "} + services + · {query} + + +
+ {children} +
+ ); +} + +export function ResultsPreview({ compact, count }: { compact: boolean; count: number }) { + const previewCount = compact ? 2 : 3; + const visible = Math.min(count, previewCount); + const remaining = Math.max(count - previewCount, 0); + + return ( +
+ {count === 0 ? ( + + No services match this filter set. + + ) : ( + <> + {SAMPLE_RESULTS.slice(0, visible).map((result) => ( +
+ + {result.name} + {result.meta} + + + {result.confidence} + +
+ ))} + {remaining > 0 ? ( + + {remaining} more + ) : null} + + )} +
+ ); +} + +/** Bottom-sheet shell used by the phone frames — an in-frame simulation of `Sheet`. */ +export function PhoneSheetShell({ + title, + description, + onClear, + children, + footer, + fullHeight = false, + maxHeight = "max-h-[72%]", +}: { + title: string; + description?: string; + onClear?: () => void; + children: React.ReactNode; + footer: React.ReactNode; + fullHeight?: boolean; + /** Overrides the default detent. Use when the surface behind the sheet is + part of the argument and must stay visible. */ + maxHeight?: string; +}) { + return ( +
+
+ +
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {onClear ? ( + + ) : null} +
+
{children}
+
+ {footer} +
+
+ ); +} + +/** The committed primary action — states its own outcome instead of "Done". */ +export function CommitButton({ + count, + compact, + disabled = false, +}: { + count: number; + compact: boolean; + disabled?: boolean; +}) { + return ( + + ); +} + +function GroupHeading({ group, showHint }: { group: FacetGroup; showHint?: boolean }) { + return ( + <> +
+ {group.label} +
+ {showHint ? ( +

{group.hint}

+ ) : null} + + ); +} + +/** The facet body every direction shares, so the three differ by frame and not by content. */ +export function FacetGroupsBody({ + selected, + counts, + onToggle, + compact, + columns = 1, + showHints = false, + needle = "", + collapsible = false, + collapsedGroups, + onToggleGroup, +}: { + selected: ReadonlySet; + counts: FacetCounts; + onToggle: (facetId: string) => void; + compact: boolean; + columns?: 1 | 2; + showHints?: boolean; + needle?: string; + collapsible?: boolean; + collapsedGroups?: ReadonlySet; + onToggleGroup?: (groupId: FacetGroupId) => void; +}) { + const trimmed = needle.trim().toLowerCase(); + const groups = FACET_GROUPS.map((group) => ({ + group, + // A selected facet stays reachable while searching: hiding it would strand + // the only control that can remove it. + facets: trimmed + ? group.facets.filter((facet) => selected.has(facet.id) || facet.label.toLowerCase().includes(trimmed)) + : group.facets, + })).filter((entry) => entry.facets.length > 0); + + if (groups.length === 0) { + return ( +

+ No filter matches “{needle}”. +

+ ); + } + + return ( +
+ {groups.map(({ group, facets }) => { + const isCollapsed = collapsible && Boolean(collapsedGroups?.has(group.id)) && !trimmed; + const chosen = group.facets.filter((facet) => selected.has(facet.id)).length; + return ( +
+ {collapsible ? ( + + ) : ( + + )} + {isCollapsed ? null : ( +
+ {facets.map((facet) => ( + onToggle(facet.id)} + /> + ))} +
+ )} +
+ ); + })} +
+ ); +} + +function PresetBlock({ compact, onRun }: { compact: boolean; onRun: () => void }) { + return ( +
+
+ +
+ Start a new search +
+
+

+ These replace the current query and clear your filters. +

+
+ {PRESETS.map((preset) => ( + + ))} +
+
+ ); +} + +function ActiveFilterBar({ + selected, + onRemove, + onClear, + compact, +}: { + selected: ReadonlySet; + onRemove: (facetId: string) => void; + onClear: () => void; + compact: boolean; +}) { + if (selected.size === 0) return null; + // Ordered by the group ladder rather than click order, so the bar does not + // reshuffle under the reader's thumb as filters are added. + const ordered = FACET_GROUPS.flatMap((group) => group.facets.filter((facet) => selected.has(facet.id))).map( + (facet) => facet.id, + ); + return ( +
+
+ {ordered.map((facetId) => ( + onRemove(facetId)} /> + ))} +
+ +
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Today — the shipped sheet, reproduced so the comparison is honest */ +/* ------------------------------------------------------------------------- */ + +function TodaySheet() { + return ( +
+ {}} + variant="trigger" + /> + +
+
+ +
+
+
+

Filter services

+

+ Quick filters run a new service search. +

+
+ + + +
+
+
+ Quick filters +
+
+ {["Current search", "Best fit", "Crisis", "Culturally safe", "Phone referral", "Free", "WA"].map( + (label, index) => ( + + {index === 0 ? : null} + {label} + + ), + )} +
+
+
+ 16 showing + + Done + +
+
+
+ ); +} + +const TODAY_DEFECTS: ReadonlyArray<{ label: string; detail: string }> = [ + { + label: "“Filter” does not filter", + detail: + "Every chip calls applyServiceQuery() and replaces the query. Choosing Crisis discards “lithium level timing” and the 16 results with it.", + }, + { + label: "One flat row, four categories", + detail: "Match quality, clinical need, cost and region share an undifferentiated chip row with no grouping.", + }, + { + label: "No counts anywhere", + detail: "Nothing tells you what a chip yields, so every choice is blind. “16 showing” is a passive footer label.", + }, + { + label: "Dead band below the fold", + detail: "Content fills roughly a third of a sheet that takes 45% of the viewport. The rest is empty surface.", + }, + { + label: "Two dismissals, no reset", + detail: + "A heavy outlined ✕ competes with the title while “Done” — the primary action — is a low-emphasis outlined button. Clear only appears once a preset is already active.", + }, + { + label: "Breakpoints disagree", + detail: + 'Phone is a role="radiogroup" (pick one); desktop is a rail of aria-pressed toggles (pick many). Same six filters, two contracts.', + }, +]; + +/* ------------------------------------------------------------------------- */ +/* Direction A — Refine in place */ +/* ------------------------------------------------------------------------- */ + +function DirectionA({ + compact, + selected, + counts, + onToggle, + onClear, +}: { + compact: boolean; + selected: ReadonlySet; + counts: FacetCounts; + onToggle: (facetId: string) => void; + onClear: () => void; +}) { + if (compact) { + return ( +
+ {}} + variant="trigger" + > + + + + 0 ? onClear : undefined} + footer={} + > + +
+ + +
+ ); + } + + return ( +
+ {}} + variant="rail" + > + + +
+ + {/* The popover the "More filters" control opens, shown open. */} +
+
+
+

Refine services

+

+ {counts.total} of {SERVICE_TOTAL} in the catalogue +

+
+ {selected.size > 0 ? ( + + ) : null} +
+
+ +
+ +
+
+ +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Direction B — Presets vs Filters, split by a visible segment */ +/* ------------------------------------------------------------------------- */ + +type SegmentValue = "narrow" | "new"; + +function SegmentSwitch({ + value, + onChange, + compact, + narrowCount, +}: { + value: SegmentValue; + onChange: (next: SegmentValue) => void; + compact: boolean; + narrowCount: number; +}) { + const segments: ReadonlyArray<{ value: SegmentValue; label: string }> = [ + { value: "narrow", label: `Narrow these ${narrowCount}` }, + { value: "new", label: "Start a new search" }, + ]; + return ( +
+ {segments.map((segment) => ( + + ))} +
+ ); +} + +function DirectionBBody({ + segment, + selected, + counts, + onToggle, + onClear, + compact, + columns, +}: { + segment: SegmentValue; + selected: ReadonlySet; + counts: FacetCounts; + onToggle: (facetId: string) => void; + onClear: () => void; + compact: boolean; + columns?: 1 | 2; +}) { + if (segment === "new") { + return ( +
+

+ Each of these replaces the current query and clears your filters — the behaviour every chip in the shipped + sheet already has, now stated. +

+ {PRESETS.map((preset) => ( + + ))} +
+ ); + } + return ( + + ); +} + +function DirectionB({ + compact, + selected, + counts, + onToggle, + onClear, + segment, + onSegment, +}: { + compact: boolean; + selected: ReadonlySet; + counts: FacetCounts; + onToggle: (facetId: string) => void; + onClear: () => void; + segment: SegmentValue; + onSegment: (next: SegmentValue) => void; +}) { + const body = (columns?: 1 | 2) => ( + + ); + + if (compact) { + return ( +
+ {}} + variant="trigger" + > + + + + 0 ? onClear : undefined} + footer={} + > +
+ +
+ {body(1)} +
+
+ ); + } + + return ( +
+ {}} + variant="trigger" + > + + +
+ +
+
+ +
+
{body(2)}
+
+ {selected.size > 0 ? ( + + ) : null} + + + +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Direction C — Directory-grade refine */ +/* ------------------------------------------------------------------------- */ + +function FilterSearchField({ + value, + onChange, + compact, +}: { + value: string; + onChange: (next: string) => void; + compact: boolean; +}) { + return ( +
+ + onChange(event.target.value)} + placeholder="Find a filter…" + aria-label="Find a filter" + className="min-w-0 flex-1 bg-transparent text-2xs font-semibold text-[color:var(--text)] outline-none placeholder:font-medium placeholder:text-[color:var(--text-soft)]" + /> + {value ? ( + + ) : null} +
+ ); +} + +function DirectionC({ + compact, + selected, + counts, + onToggle, + onClear, + needle, + onNeedle, + collapsedGroups, + onToggleGroup, +}: { + compact: boolean; + selected: ReadonlySet; + counts: FacetCounts; + onToggle: (facetId: string) => void; + onClear: () => void; + needle: string; + onNeedle: (next: string) => void; + collapsedGroups: ReadonlySet; + onToggleGroup: (groupId: FacetGroupId) => void; +}) { + const facetBody = ( + + ); + + if (compact) { + return ( +
+ {}} + variant="trigger" + /> + + 0 ? onClear : undefined} + footer={ +
+
+ {counts.total} + match · + + {popcount(and(maskForSelection(selected), DECODED["confidence:High"]))} + {" "} + high confidence · + + {popcount(and(maskForSelection(selected), DECODED["setting:digital_phone"]))} + {" "} + digital & phone +
+ +
+ } + > +
+ + {selected.size > 0 ? ( +
+ {FACET_GROUPS.flatMap((group) => group.facets.filter((facet) => selected.has(facet.id))).map( + (facet) => ( + onToggle(facet.id)} /> + ), + )} +
+ ) : null} +
+ {facetBody} +
+
+ ); + } + + return ( +
+ {/* A persistent rail, not a popover: for a 219-item directory the facets are + the primary navigation, and hiding them behind a button costs a click on + every refinement. */} + +
+ {}} + variant="trigger" + > + + + +
+ +
+
+
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Study frame */ +/* ------------------------------------------------------------------------- */ + +function PreviewFrame({ + label, + phone = false, + children, +}: { + label: string; + phone?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + + {phone ? "390 px" : "1440 px"} +
+
+
{children}
+
+
+ ); +} + +function DirectionSection({ + id, + eyebrow, + title, + lede, + strengths, + cost, + desktop, + phone, +}: { + id: string; + eyebrow: string; + title: string; + lede: string; + strengths: ReadonlyArray; + cost: string; + desktop: React.ReactNode; + phone: React.ReactNode; +}) { + return ( +
+
+
+ + {eyebrow} + +

+ {title} +

+

{lede}

+

+ Blast radius · + {cost} +

+
+
+ {strengths.map((strength) => ( + + {strength} + + ))} +
+
+
+ {desktop} + + {phone} + +
+
+ ); +} + +const anatomy: ReadonlyArray<{ term: string; detail: string }> = [ + { + term: "Filters narrow, presets navigate", + detail: + "The single change everything else follows from. A filter keeps your query and subtracts from the result set; a preset throws the query away and starts again. The shipped sheet offers only the second while promising the first.", + }, + { + term: "Every facet carries its own count", + detail: + "Counts are OR-within-group and AND-across-groups, computed against the other groups' selections — so a number answers “what would I get if I added this?” rather than collapsing to zero when a sibling is chosen.", + }, + { + term: "Zero-yield facets stay reachable", + detail: + "A facet at 0 is dashed and muted rather than removed or opacity-faded. It keeps its tab stop, survives forced-colors (where border-style is preserved and opacity is not), and tells the reader the combination is empty before they commit.", + }, + { + term: "The footer states its outcome", + detail: + "“Show 41 services” replaces the passive “16 showing” plus a generic “Done”. At zero matches it becomes an explained dead end — aria-disabled with a reason, never a native disabled that drops the tab stop.", + }, + { + term: "Active filters survive dismissal", + detail: + "A pill row under the band means the current refinement is legible without reopening the sheet, and each pill removes exactly one facet. Ordered by the group ladder, not click order, so the row does not reshuffle under a thumb.", + }, + { + term: "One contract at both widths", + detail: + "Multi-select checkboxes everywhere, retiring the phone radiogroup / desktop aria-pressed split. Whatever ships must pick one — the current disagreement is a bug in the accessible name, not a styling choice.", + }, +]; + +export function ServicesFilterRefinedMockupsPage() { + const [selected, setSelected] = useState>(() => new Set()); + const [segment, setSegment] = useState("narrow"); + const [needle, setNeedle] = useState(""); + const [collapsedGroups, setCollapsedGroups] = useState>( + () => new Set(["age", "confidence"]), + ); + const counts = useFacetCounts(selected); + + const toggleFacet = useCallback((facetId: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(facetId)) next.delete(facetId); + else next.add(facetId); + return next; + }); + }, []); + + const clearAll = useCallback(() => setSelected(new Set()), []); + + const toggleGroup = useCallback((groupId: FacetGroupId) => { + setCollapsedGroups((current) => { + const next = new Set(current); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); + return next; + }); + }, []); + + const activeSummary = + selected.size === 0 + ? "No filters applied — all 219 catalogue services." + : `${selected.size} ${selected.size === 1 ? "filter" : "filters"} applied across ${ + new Set(Array.from(selected, (facetId) => GROUP_OF_FACET.get(facetId))).size + } ${new Set(Array.from(selected, (facetId) => GROUP_OF_FACET.get(facetId))).size === 1 ? "group" : "groups"} — ${counts.total} of ${SERVICE_TOTAL} match.`; + + return ( +
+
+
+
+ + Services · filter surface + +

+ Three directions for the services filter +

+

+ The shipped sheet is titled “Filter services” and contains no filter. Every chip replaces the query and + re-runs the search, so the control that promises to narrow your results is the one that discards them. + These three directions separate the two verbs and differ in how far they take the second half. +

+

+ All counts are real, computed live from the {SERVICE_TOTAL} services in{" "} + data/services-snapshot.json. Selections are shared across all + three directions and both breakpoints, so a facet chosen on the phone updates every frame on the page. +

+
+
+ + {activeSummary} + {selected.size > 0 ? ( + + ) : null} +
+
+
+ +
+
+
+ Today +

+ What ships now, and what is wrong with it +

+

+ Reproduced from ResultFilterSheet as rendered by the services + page, so the comparison is against the real control rather than a memory of it. +

+
+
+ + + +
    + {TODAY_DEFECTS.map((defect) => ( +
  • +

    {defect.label}

    +

    {defect.detail}

    +
  • + ))} +
+
+
+ + + } + phone={} + /> + + + } + phone={ + + } + /> + + + } + phone={ + + } + /> + +
+
+

+ Anatomy +

+

+ Decisions common to all three, so the reasoning survives into whichever direction is built. +

+
+
+ {anatomy.map((entry, index) => ( +
0 && "border-t", + index % 2 === 1 && "sm:border-l", + index === 1 && "sm:border-t-0", + )} + > +
{entry.term}
+
{entry.detail}
+
+ ))} +
+
+ +
+

+ Before this becomes production +

+
    +
  1. + Filtering is new behaviour, not styling.{" "} + Services has no filter state today — the chip is the query. Any direction here needs real + selection state, URL round-tripping, and a services facet index modelled on{" "} + buildSmartDocumentTagFacetIndex in{" "} + src/lib/document-tags.ts. +
  2. +
  3. + A “No cost” facet is not free.{" "} + cost_funding is 87 distinct free-text values (“Public”, “Public + service”, “Publicly funded”, “Free”, “Not publicly stated”…). Roughly 69 of 219 match a free-ish pattern, + but that is a guess until the field is normalised. It is deliberately absent from these comps rather than + faked. +
  4. +
  5. + Not every tag is a good facet.{" "} + age_groups: mixed covers 202 of 219 and{" "} + setting_flags: public covers 207 — both are omitted here + because a facet that never excludes anything is a row of dead pixels. +
  6. +
  7. + + The radiogroup / aria-pressed split must be resolved deliberately. + {" "} + These comps pick multi-select checkboxes at both widths. If single-select survives anywhere, the roving + tabindex and arrow-key behaviour in FilterRadioGroup has to be + preserved with it. +
  8. +
  9. + Direction B and C change the band. Adding + a persistent row under the results band touches phone chrome reserve — read{" "} + docs/search-chrome-behaviour.md and run{" "} + npm run verify:phone-chrome before trusting either. +
  10. +
  11. + Mockups skip two gates, not all of them.{" "} + Button-wiring and route-reachability are exempt here; tokens, type scale, tap targets and the bundle + budget are not. Every control on this page is already at{" "} + min-h-tap (48px) on phone — do not relax that to 44px to + satisfy generic a11y guidance, which reintroduces a known{" "} + ui-smoke flake. +
  12. +
+
+
+
+ ); +}