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 |
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/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 |
diff --git a/docs/branch-review-records/badab2e50d140f081e049c934ff5e1c37b7415e4213102534a21b99bb97e87fe.record.md b/docs/branch-review-records/badab2e50d140f081e049c934ff5e1c37b7415e4213102534a21b99bb97e87fe.record.md
new file mode 100644
index 0000000000..4ade101f00
--- /dev/null
+++ b/docs/branch-review-records/badab2e50d140f081e049c934ff5e1c37b7415e4213102534a21b99bb97e87fe.record.md
@@ -0,0 +1 @@
+| 2026-08-15 | claude/services-search-redesign-163 | 5c1befa6bc7947381b707efaa50daa6e86ca9a88 | review-and-fix | P2 fixes published: serialize result-row favourite mutations and keep failed loads state-neutral and unavailable | focused services DOM tests 8/8; typecheck; lint; format:changed; branch-review-ledger; outstanding-issues; git diff --check |
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. |
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"
+ }
+}
diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx
index b02e528d70..4e1212de64 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 { useCallback, useDeferredValue, useId, useMemo, useState } from "react";
+import {
+ ArrowRight,
+ Bookmark,
+ BookmarkCheck,
+ Check,
+ ExternalLink,
+ GitCompareArrows,
+ ListChecks,
+ X,
+} from "lucide-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";
import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout";
import {
@@ -92,15 +102,27 @@ function ServiceCard({
relevanceRank,
selected,
onToggleSelected,
+ saved,
+ savedStateReady,
+ savedStateLoadFailed,
+ savePending,
+ onToggleSaved,
}: {
service: ServiceRecord;
index: number;
relevanceRank: number | null;
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;
+ savedStateLoadFailed: boolean;
+ savePending: 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. */}
+ {/* 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. */}
+
-
+ {/* 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 +493,44 @@ 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);
+ // The provider rolls back a failed mutation from its pre-request snapshot.
+ // Serialize writes from this multi-row surface so one row's rollback cannot
+ // erase another row's successful optimistic update.
+ const savingSlugsRef = useRef(new Set());
+ const [savingSlugs, setSavingSlugs] = useState>(() => new Set());
+ // 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) {
+ // 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 || accountData.loadError || savingSlugsRef.current.size > 0) return;
+ const service = searchableRecords.find((record) => record.slug === slug);
+ if (!service) return;
+ savingSlugsRef.current.add(slug);
+ setSavingSlugs(new Set(savingSlugsRef.current));
+ 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.");
+ } finally {
+ savingSlugsRef.current.delete(slug);
+ setSavingSlugs(new Set(savingSlugsRef.current));
+ }
+ }
function toggleSelected(slug: string) {
setSelectedSlugs((current) => {
@@ -532,43 +694,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 ?? ""}
+
+
0}
+ 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..953c3ea8b5
--- /dev/null
+++ b/tests/services-bookmark-readiness.dom.test.tsx
@@ -0,0 +1,145 @@
+import { fireEvent, render, screen, waitFor } 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, loadError: null as string | null, 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: accountState.loadError,
+ 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.loadError = null;
+ 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");
+ });
+
+ it("does not assert or mutate saved state after the account read fails", () => {
+ accountState.ready = true;
+ accountState.loadError = "Saved items could not be loaded.";
+ accountState.saved = [];
+ render();
+
+ const control = screen.getByRole("button", {
+ name: `Saved state unavailable for ${registryRecords[0]!.title}`,
+ });
+ expect(control).toBeDisabled();
+ expect(control).not.toHaveAttribute("aria-pressed");
+ fireEvent.click(control);
+ expect(setFavourite).not.toHaveBeenCalled();
+ });
+
+ it("serializes in-flight mutations across result rows", async () => {
+ accountState.ready = true;
+ accountState.loadError = null;
+ accountState.saved = [];
+ let finishSave: ((saved: boolean) => void) | undefined;
+ setFavourite.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishSave = resolve;
+ }),
+ );
+ render();
+
+ const control = screen.getByRole("button", { name: `Save ${registryRecords[0]!.title} to favourites` });
+ const siblingControl = screen.getByRole("button", {
+ name: `Save ${registryRecords[1]!.title} to favourites`,
+ });
+ fireEvent.click(control);
+
+ await waitFor(() => expect(control).toBeDisabled());
+ expect(siblingControl).toBeDisabled();
+ fireEvent.click(control);
+ fireEvent.click(siblingControl);
+ expect(setFavourite).toHaveBeenCalledTimes(1);
+
+ finishSave?.(true);
+ await waitFor(() => expect(control).toBeEnabled());
+ });
+});
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 272869158c..df2e4cb2c5 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -4284,20 +4284,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 ce4cfc5eb6..877af366be 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" });