From f9391318605f4a2cf743cc7a6b3448b61a8296d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 07:00:15 +0000 Subject: [PATCH 1/8] feat(services): progressive referral rail and compact result rows (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger #163 asked for query-as-H1, a progressive shortlist/compare flow, and no always-on decision panel or giant step rail on /services?q=. Re-measured against main first: most of that had already landed. The query is already the

via SearchResultsHeaderBand, the shortlist bar is already conditional, Compare already exists, and the four-card numbered walkthrough is only mounted on /services/[slug] — ui-tools already asserts it is absent from results. The 2026-07-31 baseline screenshot in the comps folder no longer reflects main. This closes the residual delta to direction B: - Add ServiceReferralProgress, a one-line Search/Shortlist/Compare/Refer dot rail whose active stage is derived from the shortlist state during render. Its accessible name is "Referral progress", deliberately not the "Referral workflow" name the removed walkthrough used, so the existing absence assertion cannot be satisfied by renaming that component back onto this route. Refer is never current here; it is reached on the record. - Compact the result row: drop the Catchment/Eligibility/Cost strip and the confidence pill. Three truncated fields per row turned a scan of 45 crisis services into clipped prose; the untruncated values are one tap away on the record, which is where a referral decision is made. - Add a per-row bookmark wired to account favourites, kept visually and semantically distinct from the shortlist: the bookmark persists across sessions, the shortlist is this search's working set and is not persisted. Its outcome is announced through a visible polite status, because "sign in to save services" is the common guest outcome and hiding it would leave the control looking broken. - Move the shortlist banner below the heading it qualifies. Deliberately unchanged: SearchResultsHeaderBand's count/query weighting. The comp draws the query dominant, but that split is a documented contract (docs/search-chrome-behaviour.md, Results band rules 1-2), is shared by twelve modes, and is visual-baselined from this exact route. The h1-is-the-query outcome this row asked for holds either way. Also unchanged: the Services ModeHome (this row's stop condition) and the filter contract PR #1878 landed. Shortlist copy stays "N shortlisted" rather than the comp's "N in shortlist" — same meaning, and it avoids churning two source-text assertions for no behavioural gain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XYphQZmsBBeqnidpSnAtjE --- .../services/services-navigator-page.tsx | 262 +++++++++++++----- ...s-navigator-scope-empty-state.dom.test.tsx | 17 ++ tests/ui-smoke.spec.ts | 9 + tests/ui-tools.spec.ts | 14 + 4 files changed, 237 insertions(+), 65 deletions(-) diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index b02e528d70..09ce211420 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -2,9 +2,19 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { ArrowRight, Check, ExternalLink, GitCompareArrows, ListChecks, ShieldCheck, X } from "lucide-react"; +import { + ArrowRight, + Bookmark, + BookmarkCheck, + Check, + ExternalLink, + GitCompareArrows, + ListChecks, + X, +} from "lucide-react"; import { useCallback, useDeferredValue, useId, useMemo, useState } from "react"; +import { useAccountData } from "@/components/account-data-provider"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { @@ -92,15 +102,18 @@ function ServiceCard({ relevanceRank, selected, onToggleSelected, + saved, + onToggleSaved, }: { service: ServiceRecord; index: number; relevanceRank: number | null; selected: boolean; onToggleSelected: (slug: string) => void; + saved: boolean; + onToggleSaved: (slug: string) => void; }) { const showBestFit = relevanceRank !== null && relevanceRank <= 2; - const catchment = service.catchments?.slice(0, 2).join(" · ") || service.location; return (
-
+
- - - {service.verification?.confidence ?? "Unknown"} confidence - + {/* Favourite, not shortlist: the bookmark persists to the account + across sessions, while the shortlist below is this search's + working set and is deliberately not persisted. Two different + jobs, so they stay two different controls. */} +
-
- {[ - ["Catchment", compactText(catchment, 72, "Confirm locally")], - ["Eligibility", compactText(service.eligibility, 86, "Review criteria")], - ["Cost", compactText(service.cost, 72, "Confirm fees")], - ].map(([label, value], itemIndex) => ( -
0 && "border-t border-[color:var(--border)] sm:border-l sm:border-t-0", - )} - > -
{label}
-
- {value} -
-
- ))} -
- + {/* The Catchment/Eligibility/Cost strip that used to sit here is gone + (direction B, ledger #163): three truncated fields per row turned a + scan of 45 crisis services into a wall of clipped prose. The full, + untruncated values are one tap away on the record via "Review + referral", which is where a referral decision is actually made. */}
stage.id === active); + + return ( + + ); +} + function ComparisonPanel({ services, onRemove, @@ -369,6 +456,30 @@ export function ServicesNavigatorPage() { const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); const heading = query || (activeGroup ? serviceCoreGroupLabel(activeGroup) : "Browse services"); + const accountData = useAccountData(); + const [saveNotice, setSaveNotice] = useState(null); + // Derived during render rather than tracked in an effect: the stage is a + // pure function of the shortlist state that already exists, and + // tests/audit-content-services-regressions.test.ts pins this file as + // effect-free. + const referralStage: ReferralStageId = showComparison ? "compare" : selectedSlugs.length ? "shortlist" : "search"; + + async function toggleSaved(slug: string) { + const service = searchableRecords.find((record) => record.slug === slug); + if (!service) return; + const nowSaved = !accountData.isSaved("service", slug); + try { + if (!(await accountData.setFavourite("service", slug, nowSaved))) { + setSaveNotice( + accountData.isAuthenticated ? "Save failed. Try again." : "Sign in or create an account to save services.", + ); + return; + } + setSaveNotice(nowSaved ? `${service.title} saved to favourites.` : `${service.title} removed from favourites.`); + } catch { + setSaveNotice("Save failed. Try again."); + } + } function toggleSelected(slug: string) { setSelectedSlugs((current) => { @@ -532,43 +643,6 @@ export function ServicesNavigatorPage() { className="mode-home-composer-slot hidden w-full min-w-0 [&:not(:empty)]:block" /> - {selected.length ? ( -
- - - {selected.length} shortlisted - - - - - -
- ) : null} - + {/* Progress, then the shortlist banner, then browse — the banner + sits under the heading it qualifies rather than above it, and + appears only once something is shortlisted. */} + + + {selected.length ? ( +
+ + + {selected.length} shortlisted + + + + + +
+ ) : null} + + {/* The bookmark control is otherwise silent, and a failed save must + not read as a success. Visible rather than sr-only: "Sign in to + save services" is the common outcome for a guest, and hiding it + from sighted readers leaves the bookmark looking simply broken. + The live region is always mounted so the announcement is not + swallowed by the node appearing at the same time as its text. */} +

+ {saveNotice ?? ""} +

+
))}
diff --git a/tests/services-navigator-scope-empty-state.dom.test.tsx b/tests/services-navigator-scope-empty-state.dom.test.tsx index d9a6d27e7a..6036ff522b 100644 --- a/tests/services-navigator-scope-empty-state.dom.test.tsx +++ b/tests/services-navigator-scope-empty-state.dom.test.tsx @@ -47,6 +47,23 @@ vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ( UniversalSearchAlsoMatches: () => null, })); +// The result row's bookmark reads account favourites. Stubbed rather than +// wrapped in the real provider so this file keeps testing scope/facet +// behaviour without also exercising the account fetch. +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ + favourites: { service: [], form: [], differential: [] }, + ready: true, + loadError: null, + error: null, + isAuthenticated: false, + isSaved: () => false, + setFavourite: vi.fn(async () => true), + clearFavourites: vi.fn(async () => true), + reload: vi.fn(), + }), +})); + import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; describe("services scope segment vs the query-empty state", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 2092ad620e..15a0625054 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4259,20 +4259,29 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(navigator.getByTestId("services-shortlist-bar")).toHaveCount(0); await expect(navigator.getByTestId("services-comparison")).toHaveCount(0); + // The dot rail is progressive: it tracks the shortlist state rather than + // standing there as an always-on four-card walkthrough (ledger #163). + const progress = navigator.getByRole("navigation", { name: "Referral progress" }); + const currentStage = progress.locator('[aria-current="step"]'); + await expect(currentStage).toHaveText("Search"); + const addButtons = navigator.getByRole("button", { name: /Add .* to shortlist/ }); await addButtons.nth(0).click(); const shortlist = navigator.getByTestId("services-shortlist-bar"); await expect(shortlist).toContainText("1 shortlisted"); await expect(shortlist.getByRole("button", { name: "Compare" })).toBeDisabled(); + await expect(currentStage).toHaveText("Shortlist"); await addButtons.nth(1).click(); await expect(shortlist).toContainText("2 shortlisted"); await shortlist.getByRole("button", { name: "Compare" }).click(); await expect(navigator.getByTestId("services-comparison")).toBeVisible(); + await expect(currentStage).toHaveText("Compare"); await shortlist.getByRole("button", { name: "Clear" }).click(); await expect(navigator.getByTestId("services-shortlist-bar")).toHaveCount(0); await expect(navigator.getByTestId("services-comparison")).toHaveCount(0); + await expect(currentStage).toHaveText("Search"); }); test("search regressions avoid fetch errors and open viewer hits @critical", async ({ page }) => { diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index e8c9f7912f..08ea90b41a 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1392,9 +1392,23 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByRole("heading", { level: 1, name: "13YARN" })).toBeVisible(); await expect(page.getByLabel("Referral workflow")).toHaveCount(0); + // The four-card numbered walkthrough stays gone (assertion above); what + // replaces it is a one-line dot rail under a DIFFERENT accessible name, + // so the check above cannot be satisfied by quietly renaming the old + // component back onto this route (ledger #163). + const referralProgress = page.getByRole("navigation", { name: "Referral progress" }); + await expect(referralProgress).toBeVisible(); + await expect(referralProgress.locator('[aria-current="step"]')).toHaveText("Search"); await expect(page.getByRole("navigation", { name: "Service groups" })).toBeVisible(); await expect(page.getByTestId("services-shortlist-bar")).toHaveCount(0); + // The row is compact by contract: the Catchment/Eligibility/Cost strip + // moved to the record, and the bookmark is a persisted favourite that is + // deliberately distinct from the in-page shortlist. + const firstResult = page.getByTestId("service-search-result-13yarn"); + await expect(firstResult.getByText("Catchment", { exact: true })).toHaveCount(0); + await expect(firstResult.getByRole("button", { name: "Save 13YARN to favourites" })).toBeVisible(); + const culturallySafe = page .getByTestId("service-quick-search-suggestions") .getByRole("button", { name: "Culturally safe" }); From 285e12d3c939c6c87a9f6d1b007e1fe227b86849 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 07:13:13 +0000 Subject: [PATCH 2/8] docs(ledger): review record and #163 queue requests for PR #1982 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Travels with the owning PR rather than a ledger-only branch, per AGENTS.md "PR bundling" — these are independent immutable files, so they carry no conflict risk against a concurrent reconcile. - Branch review record for f939131. - issues:done for #163, recording the split explicitly: what was already on main before this PR versus what this PR delivered, and the one part left deliberately undone (the shared band's count/query weighting). - issues:add for the comps-vs-contract divergence that left behind, so the next implementer finds a decision to make rather than a silent gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XYphQZmsBBeqnidpSnAtjE --- ...0519cc84a4cee33bf9c809091f21ed28f76192.record.md | 1 + .../0e5573e3-425d-4253-9b28-0c1c81a4f3fe.json | 10 ++++++++++ .../568597bd-fc9f-47d6-b582-443812115e67.json | 13 +++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 docs/branch-review-records/3fbcef2b507598e6baa40944420519cc84a4cee33bf9c809091f21ed28f76192.record.md create mode 100644 docs/outstanding-issues-inbox/0e5573e3-425d-4253-9b28-0c1c81a4f3fe.json create mode 100644 docs/outstanding-issues-inbox/568597bd-fc9f-47d6-b582-443812115e67.json diff --git a/docs/branch-review-records/3fbcef2b507598e6baa40944420519cc84a4cee33bf9c809091f21ed28f76192.record.md b/docs/branch-review-records/3fbcef2b507598e6baa40944420519cc84a4cee33bf9c809091f21ed28f76192.record.md new file mode 100644 index 0000000000..2ab488d9f5 --- /dev/null +++ b/docs/branch-review-records/3fbcef2b507598e6baa40944420519cc84a4cee33bf9c809091f21ed28f76192.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/services-search-redesign-163 | f9391318605f4a2cf743cc7a6b3448b61a8296d4 | Services search results: progressive referral rail, compact rows, favourites bookmark (#163) | Shipped; row re-measured as half-stale before building — no P0-P2 findings | verify:pr-local all 10 selected gates passed (runtime, lock parity, format:changed, lint, typecheck, test, build, rag fixtures, medication interactions, lexicon report); focused vitest 22/22 across services scope-empty-state, services regressions audit, band adoption, route reachability; check:design-system-contract passed at 25 edge conflicts unchanged; UI delegated to CI Production UI — chromium-1194 vs pinned 1234 (#255/#312) | diff --git a/docs/outstanding-issues-inbox/0e5573e3-425d-4253-9b28-0c1c81a4f3fe.json b/docs/outstanding-issues-inbox/0e5573e3-425d-4253-9b28-0c1c81a4f3fe.json new file mode 100644 index 0000000000..e4d591766e --- /dev/null +++ b/docs/outstanding-issues-inbox/0e5573e3-425d-4253-9b28-0c1c81a4f3fe.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "id": "0e5573e3-425d-4253-9b28-0c1c81a4f3fe", + "createdOn": "2026-08-15", + "action": "done", + "payload": { + "id": "#163", + "outcome": "CLOSED 2026-08-15 by PR #1982, but the row was half-stale when picked up and the split is worth recording. ALREADY ON MAIN before this PR, verified against e60b49a: the query is the

via SearchResultsHeaderBand headingLevel={1} (the count is separate neutral text, never a heading), the shortlist bar is already conditional on selected.length, Compare already exists, and the four-card numbered walkthrough (ServiceReferralFlow) is mounted only on /services/[slug] — tests/ui-tools.spec.ts:1394 already asserted it absent from results. The current/ baseline PNG the row was written against is from 2026-07-31 and no longer reflects main, which is why the row read as untouched work. DELIVERED BY THIS PR: the tiny Search/Shortlist/Compare/Refer dot rail (new ServiceReferralProgress, accessible name Referral progress — deliberately not Referral workflow, so the absence assertion cannot be satisfied by renaming the old component back onto the route); row compaction dropping the Catchment/Eligibility/Cost strip and the confidence pill (both values remain untruncated on the record behind Review referral); a per-row bookmark wired to account favourites and kept distinct from the non-persisted shortlist, with a visible polite status because sign-in-required is the common guest outcome; and the shortlist banner moved below the heading it qualifies. NOT DONE, deliberately: the comp draws the query dominant and the count small, which is the reverse of the shipped band. That weighting is a documented contract (docs/search-chrome-behaviour.md Results band rules 1-2), shared by twelve modes, and visual-baselined from /services?q=CMHT&run=1, so inverting it is a repo-wide change and not this row. The outcome this row asked for holds either way. Captured separately. Verification: verify:pr-local all ten selected gates passed; UI proof delegated to CI Production UI because the container ships chromium-1194 against a pinned 1234 (#255/#312)." + } +} diff --git a/docs/outstanding-issues-inbox/568597bd-fc9f-47d6-b582-443812115e67.json b/docs/outstanding-issues-inbox/568597bd-fc9f-47d6-b582-443812115e67.json new file mode 100644 index 0000000000..add66a2d5a --- /dev/null +++ b/docs/outstanding-issues-inbox/568597bd-fc9f-47d6-b582-443812115e67.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "id": "568597bd-fc9f-47d6-b582-443812115e67", + "createdOn": "2026-08-15", + "action": "add", + "payload": { + "pri": "P3", + "type": "rec", + "summary": "The mode-page comps and the results-band weighting contract disagree about query vs count emphasis", + "detail": "Found while shipping #163 (PR #1982). The perfected-combined comps draw the search query large and bold with the match count small and muted beside it, on both /tools and /services. Production does the reverse: SearchResultsHeaderBand renders the count first at font-weight 600 with the query at 450 and muted, and that is not an accident — docs/search-chrome-behaviour.md 'Results band' rules 1 and 2 argue for it explicitly (the query is the sole heading and the count is never one; nothing in the band is bold; the two weights are deliberately near-adjacent steps of one scale separated by tabular numerals and a hairline rather than by shouting). The band is shared by twelve modes and is a visual-baseline target captured from /services?q=CMHT&run=1 (tests/ui-visual-baseline.spec.ts), so changing it is a repo-wide change with a baseline refresh, not a per-mode tweak. #163 was closed without touching it because the outcome that row asked for — query-as-H1 rather than a match-count heading — is already true either way. Next: decide deliberately which artefact is authoritative. If the comps win, the change is a shared-band edit plus a rewrite of Results band rules 1-2 plus refreshed search-results-band and search-results-band-phone baselines, and it should be one PR covering all twelve modes. If the contract wins, the comps should carry a note so the next implementer does not re-open this. Stop: do not add a per-mode variant prop to make services alone read query-dominant — that makes shared chrome mode-conditional to settle a question that has one answer.", + "source": "session 2026-08-15; PR #1982; docs/search-chrome-behaviour.md Results band rules 1-2" + } +} From 181d69aa02791953059181cd20f56549d73cc239 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:34:08 +0800 Subject: [PATCH 3/8] docs(ledger): record services bookmark fix --- ...90b9641c9a9cf0446c884067c3339f7a2f50cd5c5e642b9988e.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/216a72e41232d90b9641c9a9cf0446c884067c3339f7a2f50cd5c5e642b9988e.record.md diff --git a/docs/branch-review-records/216a72e41232d90b9641c9a9cf0446c884067c3339f7a2f50cd5c5e642b9988e.record.md b/docs/branch-review-records/216a72e41232d90b9641c9a9cf0446c884067c3339f7a2f50cd5c5e642b9988e.record.md new file mode 100644 index 0000000000..1a42fed151 --- /dev/null +++ b/docs/branch-review-records/216a72e41232d90b9641c9a9cf0446c884067c3339f7a2f50cd5c5e642b9988e.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/services-search-redesign-163 | 720e7027a9f08e518eb6344e74dfde35d78d5981 | Fix service bookmark readiness and mutation race; merge current main | fixed | git diff --check; focused DOM test blocked without node_modules; ledger and issue guards | From 87e7fcc20abc30870694be1bc924ac0f48752464 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:26:31 +0800 Subject: [PATCH 4/8] docs(ledger): record services base sync --- ...b6a48c81f5c8d59fd2b19edd41a2f38f86c05ae354673816ce5.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/d566ba015a558b6a48c81f5c8d59fd2b19edd41a2f38f86c05ae354673816ce5.record.md diff --git a/docs/branch-review-records/d566ba015a558b6a48c81f5c8d59fd2b19edd41a2f38f86c05ae354673816ce5.record.md b/docs/branch-review-records/d566ba015a558b6a48c81f5c8d59fd2b19edd41a2f38f86c05ae354673816ce5.record.md new file mode 100644 index 0000000000..60a40dbbb8 --- /dev/null +++ b/docs/branch-review-records/d566ba015a558b6a48c81f5c8d59fd2b19edd41a2f38f86c05ae354673816ce5.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/services-search-redesign-163 | c6247862cb109e795f73b8aeeeb971436016c6c4 | PR #1982 base sync | Merged main 6f7b7deefaf7e0cd062b748f18fc6ca8988093f6 into the reviewed PR head; merge tree was clean. | git merge-tree --write-tree exact-head main: clean; git diff --check; ledger guards. | From d007d4a11a0d7b52283fe0088583259ef27c0264 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:27:48 +0000 Subject: [PATCH 5/8] fix(services): do not assert a bookmark state before the account read settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountDataProvider initialises `ready` to `auth.status !== "authenticated"`, so for a signed-in reader it is false until the favourites GET resolves, and `favourites` is empty for that whole window. `isSaved()` therefore answered "no" for every service — not because nothing was saved, but because nothing had been read yet. The row rendered "Save X to favourites" on a service the reader had already saved, and a tap issued a redundant write inverting a state that was never loaded. The control is now inert until the read settles: native `disabled` (transient inertness, which is what docs/wiring-conventions.md reserves `disabled` for, as against the aria-disabled placeholder pattern for permanently-unavailable actions), no `aria-pressed` at all rather than a confidently wrong one, and an accessible name that says it is loading. `toggleSaved` refuses early too, so a programmatic call cannot invert an unread state either. Regression cover in tests/services-bookmark-readiness.dom.test.tsx, mutation- verified: reverting the gate fails the first case and leaves the second passing, so the test discriminates the defect rather than the feature. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XYphQZmsBBeqnidpSnAtjE --- .../services/services-navigator-page.tsx | 40 +++++-- .../services-bookmark-readiness.dom.test.tsx | 100 ++++++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 tests/services-bookmark-readiness.dom.test.tsx diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 09ce211420..d143f67dd0 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -103,6 +103,7 @@ function ServiceCard({ selected, onToggleSelected, saved, + savedStateReady, onToggleSaved, }: { service: ServiceRecord; @@ -111,6 +112,10 @@ function ServiceCard({ selected: boolean; onToggleSelected: (slug: string) => void; saved: boolean; + // False until the account favourites read settles. Until then `saved` is + // `false` for EVERY service — not because nothing is saved, but because + // nothing has been read yet — so the control must not assert a state. + savedStateReady: boolean; onToggleSaved: (slug: string) => void; }) { const showBestFit = relevanceRank !== null && relevanceRank <= 2; @@ -172,19 +177,37 @@ function ServiceCard({ across sessions, while the shortlist below is this search's working set and is deliberately not persisted. Two different jobs, so they stay two different controls. */} + {/* Native `disabled`, not aria-disabled: this is transient inertness + while a request settles, which is exactly the case + docs/wiring-conventions.md reserves `disabled` for. Without it the + control asserts "not saved" for every service during the account + read and a tap issues a redundant write against a service that is + already saved. */} @@ -465,6 +488,10 @@ export function ServicesNavigatorPage() { const referralStage: ReferralStageId = showComparison ? "compare" : selectedSlugs.length ? "shortlist" : "search"; async function toggleSaved(slug: string) { + // Belt as well as braces: the control is disabled until the read settles, + // but a toggle computed from an unread library would invert the wrong + // state, so refuse it here too rather than trusting the caller. + if (!accountData.ready) return; const service = searchableRecords.find((record) => record.slug === slug); if (!service) return; const nowSaved = !accountData.isSaved("service", slug); @@ -871,6 +898,7 @@ export function ServicesNavigatorPage() { selected={selectedSlugs.includes(service.slug)} onToggleSelected={toggleSelected} saved={accountData.isSaved("service", service.slug)} + savedStateReady={accountData.ready} onToggleSaved={toggleSaved} /> ))} diff --git a/tests/services-bookmark-readiness.dom.test.tsx b/tests/services-bookmark-readiness.dom.test.tsx new file mode 100644 index 0000000000..324d8968ce --- /dev/null +++ b/tests/services-bookmark-readiness.dom.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { loadServicesSnapshot } from "@/lib/service-catalog"; +import { mapCatalogToServiceRecords } from "@/lib/service-catalog-mapper"; + +const registryRecords = mapCatalogToServiceRecords(loadServicesSnapshot().services).slice(0, 3); + +const accountState = vi.hoisted(() => ({ ready: true, saved: [] as string[] })); +const setFavourite = vi.hoisted(() => vi.fn(async () => true)); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/lib/use-registry-records", () => ({ + useRegistryRecords: () => ({ + status: "ready", + records: registryRecords, + total: registryRecords.length, + demoMode: true, + governance: {}, + refetch: vi.fn(), + }), +})); + +vi.mock("@/components/use-result-sort", () => ({ + useResultSort: () => ["relevance", vi.fn()] as const, +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ + favourites: { service: accountState.saved, form: [], differential: [] }, + ready: accountState.ready, + loadError: null, + error: null, + isAuthenticated: true, + isSaved: (_type: string, key: string) => accountState.saved.includes(key), + setFavourite, + clearFavourites: vi.fn(async () => true), + reload: vi.fn(), + }), +})); + +import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; + +/** + * Regression cover for the readiness half of the bookmark control. + * + * `AccountDataProvider` initialises `ready` to `auth.status !== "authenticated"`, + * so for a signed-in reader it is FALSE until the favourites GET resolves, and + * `favourites` is empty for that whole window. `isSaved()` therefore answers + * "no" for every service — not because nothing is saved, but because nothing has + * been read yet. Without a readiness gate the row asserts "Save X to favourites" + * on a service the reader has already saved, and a tap issues a redundant write + * inverting a state that was never loaded. + */ +describe("services result bookmark readiness", () => { + it("does not assert a saved state while the account read is still in flight", () => { + accountState.ready = false; + accountState.saved = [registryRecords[0]!.slug]; + render(); + + const control = screen.getByRole("button", { name: `Loading saved state for ${registryRecords[0]!.title}` }); + expect(control).toBeDisabled(); + // The critical assertion: it must not claim "not saved" for a service that + // IS saved. No aria-pressed at all beats a confidently wrong one. + expect(control).not.toHaveAttribute("aria-pressed"); + expect( + screen.queryByRole("button", { name: `Save ${registryRecords[0]!.title} to favourites` }), + ).not.toBeInTheDocument(); + }); + + it("reports the real saved state once the read settles", () => { + accountState.ready = true; + accountState.saved = [registryRecords[0]!.slug]; + render(); + + const saved = screen.getByRole("button", { name: `Remove ${registryRecords[0]!.title} from favourites` }); + expect(saved).toBeEnabled(); + expect(saved).toHaveAttribute("aria-pressed", "true"); + + const unsaved = screen.getByRole("button", { name: `Save ${registryRecords[1]!.title} to favourites` }); + expect(unsaved).toHaveAttribute("aria-pressed", "false"); + }); +}); From e6618066fb74262eac04231394dc99830527a7c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:29:13 +0000 Subject: [PATCH 6/8] docs(ledger): supersede the false services bookmark-fix record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record 216a72e4 on this branch claimed "Fix service bookmark readiness and mutation race" at head 720e7027a9f08e518eb6344e74dfde35d78d5981. Verified against the refs rather than taken at face value, per AGENTS.md "Third-party fix claims stay unverified until checked": - git cat-file -t 720e7027... — object does not exist in this repository. - git diff 285e12d..87e7fcc2 -- src/components/services/ — empty. No code change had landed under that claim. The ledger records are immutable, so this supersedes rather than edits. The defect the record named was real, and the preceding commit fixes it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XYphQZmsBBeqnidpSnAtjE --- ...237b0b0d3028b47dde0cd7d2e44c4b79c2badd5c8be19153682.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/60422ff1c15e4237b0b0d3028b47dde0cd7d2e44c4b79c2badd5c8be19153682.record.md diff --git a/docs/branch-review-records/60422ff1c15e4237b0b0d3028b47dde0cd7d2e44c4b79c2badd5c8be19153682.record.md b/docs/branch-review-records/60422ff1c15e4237b0b0d3028b47dde0cd7d2e44c4b79c2badd5c8be19153682.record.md new file mode 100644 index 0000000000..9c62fd4350 --- /dev/null +++ b/docs/branch-review-records/60422ff1c15e4237b0b0d3028b47dde0cd7d2e44c4b79c2badd5c8be19153682.record.md @@ -0,0 +1 @@ +| 2026-08-15 | claude/services-search-redesign-163 | d007d4a11a0d7b52283fe0088583259ef27c0264 | Services bookmark readiness — correcting the 216a72e4 record | PRIOR RECORD WAS FALSE. Record 216a72e4 claimed 'Fix service bookmark readiness and mutation race' at head 720e7027a9f08e518eb6344e74dfde35d78d5981. That object does not exist in this repository (git cat-file fails; not an ancestor of the branch) and src/components/services/ was byte-identical between 285e12d3c939 and the then-remote tip 87e7fcc2 — no fix had landed. The named defect was real and is fixed here instead | git cat-file -t on the claimed head: object does not exist; git diff 285e12d..87e7fcc2 -- src/components/services/: empty; new DOM regression test mutation-verified (reverting the gate fails it); focused vitest 12/12; typecheck clean | From 5c1befa6bc7947381b707efaa50daa6e86ca9a88 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:09:01 +0800 Subject: [PATCH 7/8] fix(services): harden result-row favourite mutations --- .../services/services-navigator-page.tsx | 50 +++++++++++++----- .../services-bookmark-readiness.dom.test.tsx | 51 +++++++++++++++++-- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index d143f67dd0..4e1212de64 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -12,7 +12,7 @@ import { ListChecks, X, } from "lucide-react"; -import { useCallback, useDeferredValue, useId, useMemo, useState } from "react"; +import { useCallback, useDeferredValue, useId, useMemo, useRef, useState } from "react"; import { useAccountData } from "@/components/account-data-provider"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; @@ -104,6 +104,8 @@ function ServiceCard({ onToggleSelected, saved, savedStateReady, + savedStateLoadFailed, + savePending, onToggleSaved, }: { service: ServiceRecord; @@ -116,6 +118,8 @@ function ServiceCard({ // `false` for EVERY service — not because nothing is saved, but because // nothing has been read yet — so the control must not assert a state. savedStateReady: boolean; + savedStateLoadFailed: boolean; + savePending: boolean; onToggleSaved: (slug: string) => void; }) { const showBestFit = relevanceRank !== null && relevanceRank <= 2; @@ -186,24 +190,34 @@ function ServiceCard({