From 7b64f2559741a9f353adcf939745831e0daff7db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:38:31 +0000 Subject: [PATCH 1/7] feat(mockups): three directions for the services filter surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The services filter sheet is titled "Filter services" and contains no filter. 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. Adds /mockups/services-filter-refined: a reproduction of what ships today with its defects annotated, then three directions, each at desktop and phone. - A · Refine in place — facet groups with live counts above a visually separated "Start a new search" block. Expressible in the current ResultFilterSheet props (the unused ResultFilterOption.hint field already exists for counts); upgrades all 7 modes, nothing forks. - B · Presets and filters, split — two tabs so the reader picks a verb before a value, plus a persistent active-filter row under the band. - C · Directory-grade refine — a persistent desktop rail instead of a popover, and a phone sheet with find-a-filter and collapsible groups. Every count 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). Those tags are already populated and entirely unused by the UI today. Recorded caveats: cost_funding is 87 distinct free-text values so a "No cost" facet needs normalising first and is deliberately absent rather than faked; age_groups:mixed (202/219) and setting_flags:public (207/219) are omitted because a facet that never excludes anything is a row of dead pixels; and the phone radiogroup / desktop aria-pressed disagreement must be resolved deliberately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J --- docs/site-map.md | 1 + mockups/README.md | 29 + src/app/mockups/mockups-layout-client.tsx | 6 + .../mockups/services-filter-refined/page.tsx | 13 + .../services-filter-refined-mockups.tsx | 1695 +++++++++++++++++ 5 files changed, 1744 insertions(+) create mode 100644 src/app/mockups/services-filter-refined/page.tsx create mode 100644 src/components/services-filter-refined-mockups.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 4a7df112bc..56bfa8c8c0 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1030,6 +1030,7 @@ 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-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..3c4738c5bc 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -74,3 +74,32 @@ 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. diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 4a2c9d166f..b97b6b3b67 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -29,6 +29,10 @@ 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"; 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 +88,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isCalculatorsSearchPageMockup && !isPhoneInPageNavigationMockup && !isSearchBandDirectionsMockup && + !isServicesFilterRefinedMockup && !isTherapyNavigationMockup && !isWarningConsolidationMockup && !isWarningLineMockup && @@ -99,6 +104,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isSearchHeadingMockup && !isSearchRefineAdaptiveMockup && !isSearchBandDirectionsMockup && + !isServicesFilterRefinedMockup && !isPhoneInPageNavigationMockup && !isTherapyNavigationMockup && !isWarningConsolidationMockup && 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-refined-mockups.tsx b/src/components/services-filter-refined-mockups.tsx new file mode 100644 index 0000000000..202dc271dd --- /dev/null +++ b/src/components/services-filter-refined-mockups.tsx @@ -0,0 +1,1695 @@ +"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) */ +/* ------------------------------------------------------------------------- */ + +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==", +}; + +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 }>; +}; + +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); + const bytes = new Uint8Array(MASK_LENGTH); + for (let index = 0; index < binary.length && 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; +} + +type FacetCounts = { total: number; perFacet: Record }; + +function useFacetCounts(selected: ReadonlySet): FacetCounts { + return useMemo(() => { + const total = popcount(maskForSelection(selected)); + const perFacet: Record = {}; + for (const group of FACET_GROUPS) { + // Counts for a group are computed against every OTHER group's selection, + // so the numbers answer "what would I get if I added this?" rather than + // collapsing to zero the moment a sibling in the same group is chosen. + const base = maskForSelection(selected, group.id); + for (const facet of group.facets) { + perFacet[facet.id] = popcount(and(base, DECODED[facet.id])); + } + } + return { total, perFacet }; + }, [selected]); +} + +/* ------------------------------------------------------------------------- */ +/* Presets — the six that ship today, unchanged in meaning */ +/* ------------------------------------------------------------------------- */ + +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 */ +/* ------------------------------------------------------------------------- */ + +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"; +} + +function FacetChip({ + label, + count, + selected, + compact, + onToggle, +}: { + label: string; + count: number; + selected: boolean; + compact: boolean; + onToggle: () => void; +}) { + const empty = count === 0 && !selected; + return ( + + ); +} + +function ActiveFilterPill({ facetId, onRemove, compact }: { facetId: string; onRemove: () => void; compact: boolean }) { + return ( + + {FACET_LABEL.get(facetId)} + + + ); +} + +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. */ +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} +
+ ); +} + +function ResultsPreview({ compact, count }: { compact: boolean; count: number }) { + return ( +
+ {SAMPLE_RESULTS.slice(0, compact ? 2 : 3).map((result) => ( +
+ + {result.name} + {result.meta} + + + {result.confidence} + +
+ ))} + + + {Math.max(count - (compact ? 2 : 3), 0)} more + +
+ ); +} + +/** Bottom-sheet shell used by the phone frames — an in-frame simulation of `Sheet`. */ +function PhoneSheetShell({ + title, + description, + onClear, + children, + footer, + fullHeight = false, +}: { + title: string; + description?: string; + onClear?: () => void; + children: React.ReactNode; + footer: React.ReactNode; + fullHeight?: boolean; +}) { + return ( +
+
+ +
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {onClear ? ( + + ) : null} +
+
{children}
+
+ {footer} +
+
+ ); +} + +/** The committed primary action — states its own outcome instead of "Done". */ +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. */ +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 ·{counts.perFacet["confidence:High"] ?? 0} high + confidence ·{counts.perFacet["setting:digital_phone"] ?? 0}{" "} + 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. +
+
+
+
+ ); +} From 17ca6be42e8b5b1c78866670371ec6b0d59615b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:55:35 +0000 Subject: [PATCH 2/7] docs(review): record services filter mockups handoff Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a20a6a2779..670464c240 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -883,3 +883,4 @@ 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 | 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 | From bff7050134fa1b7c8e3a832b43d70502254b46ab Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:05:32 +0800 Subject: [PATCH 3/7] fix(mockups): align services filter counts and preview with live total --- .../services-filter-refined-mockups.tsx | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/src/components/services-filter-refined-mockups.tsx b/src/components/services-filter-refined-mockups.tsx index 202dc271dd..0a936f734c 100644 --- a/src/components/services-filter-refined-mockups.tsx +++ b/src/components/services-filter-refined-mockups.tsx @@ -249,12 +249,10 @@ function useFacetCounts(selected: ReadonlySet): FacetCounts { const total = popcount(maskForSelection(selected)); const perFacet: Record = {}; for (const group of FACET_GROUPS) { - // Counts for a group are computed against every OTHER group's selection, - // so the numbers answer "what would I get if I added this?" rather than - // collapsing to zero the moment a sibling in the same group is chosen. - const base = maskForSelection(selected, group.id); for (const facet of group.facets) { - perFacet[facet.id] = popcount(and(base, DECODED[facet.id])); + const candidate = new Set(selected); + candidate.add(facet.id); + perFacet[facet.id] = popcount(maskForSelection(candidate)); } } return { total, perFacet }; @@ -456,25 +454,35 @@ function ResultsBand({ } 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 (
- {SAMPLE_RESULTS.slice(0, compact ? 2 : 3).map((result) => ( -
- - {result.name} - {result.meta} - - - {result.confidence} - -
- ))} - - + {Math.max(count - (compact ? 2 : 3), 0)} more - + {count === 0 ? ( + + No services match this filter set. + + ) : ( + <> + {SAMPLE_RESULTS.slice(0, visible).map((result) => ( +
+ + {result.name} + {result.meta} + + + {result.confidence} + +
+ ))} + + {remaining} more + + )}
); } From 44206f8fd78495606c55a22336860e646e8a110b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:24:59 +0800 Subject: [PATCH 4/7] fix(mockups): adjust services preview counts --- src/components/services-filter-refined-mockups.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/services-filter-refined-mockups.tsx b/src/components/services-filter-refined-mockups.tsx index 0a936f734c..de5766ce31 100644 --- a/src/components/services-filter-refined-mockups.tsx +++ b/src/components/services-filter-refined-mockups.tsx @@ -480,7 +480,9 @@ function ResultsPreview({ compact, count }: { compact: boolean; count: number })
))} - + {remaining} more + {remaining > 0 ? ( + + {remaining} more + ) : null} )}
@@ -1227,8 +1229,14 @@ function DirectionC({
{counts.total} - match ·{counts.perFacet["confidence:High"] ?? 0} high - confidence ·{counts.perFacet["setting:digital_phone"] ?? 0}{" "} + match · + + {popcount(and(maskForSelection(selected), DECODED["confidence:High"]))} + {" "} + high confidence · + + {popcount(and(maskForSelection(selected), DECODED["setting:digital_phone"]))} + {" "} digital & phone
From 460c46ef1248540efded8a0636f7122bcfcb7c2b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:44:39 +0800 Subject: [PATCH 5/7] fix(mockup): validate mockup masks and simplify segment switch semantics --- src/components/services-filter-refined-mockups.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/services-filter-refined-mockups.tsx b/src/components/services-filter-refined-mockups.tsx index de5766ce31..e94a1f6a36 100644 --- a/src/components/services-filter-refined-mockups.tsx +++ b/src/components/services-filter-refined-mockups.tsx @@ -185,8 +185,11 @@ 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 < binary.length && index < MASK_LENGTH; index += 1) { + for (let index = 0; index < MASK_LENGTH; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; @@ -954,7 +957,7 @@ function SegmentSwitch({ ]; return (
@@ -962,8 +965,7 @@ function SegmentSwitch({ + ); +} + +/** 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 index 202dc271dd..856f2a2e95 100644 --- a/src/components/services-filter-refined-mockups.tsx +++ b/src/components/services-filter-refined-mockups.tsx @@ -57,7 +57,7 @@ import { cn } from "@/components/ui-primitives"; /* Facet index — generated from data/services-snapshot.json (219 services) */ /* ------------------------------------------------------------------------- */ -const SERVICE_TOTAL = 219; +export const SERVICE_TOTAL = 219; /** * One base64 bitmask per facet, 219 bits wide, index-aligned to the snapshot's @@ -91,7 +91,7 @@ const FACET_MASKS: Record = { "confidence:Low": "CAAAAAAAQBAAAAAAAAAAAAAAAAAAAABAAAAAAA==", }; -type FacetGroupId = "acuity" | "catchment" | "age" | "setting" | "focus" | "confidence"; +export type FacetGroupId = "acuity" | "catchment" | "age" | "setting" | "focus" | "confidence"; type FacetGroup = { id: FacetGroupId; @@ -101,7 +101,7 @@ type FacetGroup = { facets: ReadonlyArray<{ id: string; label: string }>; }; -const FACET_GROUPS: ReadonlyArray = [ +export const FACET_GROUPS: ReadonlyArray = [ { id: "acuity", label: "Acuity", @@ -242,9 +242,9 @@ function maskForSelection(selected: ReadonlySet, skipGroup?: FacetGroupI return result; } -type FacetCounts = { total: number; perFacet: Record }; +export type FacetCounts = { total: number; perFacet: Record }; -function useFacetCounts(selected: ReadonlySet): FacetCounts { +export function useFacetCounts(selected: ReadonlySet): FacetCounts { return useMemo(() => { const total = popcount(maskForSelection(selected)); const perFacet: Record = {}; @@ -265,7 +265,7 @@ function useFacetCounts(selected: ReadonlySet): FacetCounts { /* Presets — the six that ship today, unchanged in meaning */ /* ------------------------------------------------------------------------- */ -const PRESETS: ReadonlyArray<{ id: string; label: string; detail: string }> = [ +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" }, @@ -286,7 +286,7 @@ const SAMPLE_RESULTS: ReadonlyArray<{ name: string; meta: string; confidence: st /* Shared control primitives */ /* ------------------------------------------------------------------------- */ -const focusRing = +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. */ @@ -294,7 +294,7 @@ function tapHeight(compact: boolean) { return compact ? "min-h-tap" : "min-h-9"; } -function FacetChip({ +export function FacetChip({ label, count, selected, @@ -346,7 +346,15 @@ function FacetChip({ ); } -function ActiveFilterPill({ facetId, onRemove, compact }: { facetId: string; onRemove: () => void; compact: boolean }) { +export function ActiveFilterPill({ + facetId, + onRemove, + compact, +}: { + facetId: string; + onRemove: () => void; + compact: boolean; +}) { return ( {SAMPLE_RESULTS.slice(0, compact ? 2 : 3).map((result) => ( @@ -480,13 +488,14 @@ function ResultsPreview({ compact, count }: { compact: boolean; count: number }) } /** Bottom-sheet shell used by the phone frames — an in-frame simulation of `Sheet`. */ -function PhoneSheetShell({ +export function PhoneSheetShell({ title, description, onClear, children, footer, fullHeight = false, + maxHeight = "max-h-[72%]", }: { title: string; description?: string; @@ -494,12 +503,15 @@ function PhoneSheetShell({ 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 (
@@ -535,7 +547,15 @@ function PhoneSheetShell({ } /** The committed primary action — states its own outcome instead of "Done". */ -function CommitButton({ count, compact, disabled = false }: { count: number; compact: boolean; disabled?: boolean }) { +export function CommitButton({ + count, + compact, + disabled = false, +}: { + count: number; + compact: boolean; + disabled?: boolean; +}) { return (