From 82b71b3f4a0b81bd78253be726e66d6fe98c829c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:05:36 +0800 Subject: [PATCH 01/10] fix: restore overwritten search features --- AGENTS.md | 4 +- docs/branch-review-ledger.md | 1 + .../global-search-shell.tsx | 1 + .../master-search-header.tsx | 4 + .../universal-search-command-surface.tsx | 92 ++++++++++++++++--- src/lib/catalog-search.ts | 70 +++++++++++++- .../audit-navigation-auth-regressions.test.ts | 11 +++ tests/catalog-search.test.ts | 19 +++- tests/ui-overlap.spec.ts | 7 +- 9 files changed, 194 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88ec727f80..483df4b4c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ # This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 7fd691776a..7f30439f72 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -900,3 +900,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-12 | PR #1815 / claude/spacing-icon-design-review-rxwh28 | 9f266210f02081be54d407c70a85f52fed436128 | babysit | no remaining actionable findings; one pre-existing thread resolved as no-change (Dockerfile.worker follow-up needed) | required checks: Gitleaks PR policy PR required (all pass); targeted vitest passed: tests/document-frame-contract.test.ts + tests/in-page-nav-header.dom.test.tsx | | 2026-08-12 | 1815 | 27ce96e1755055ceee2eeae02d6efdf11259fcde | babysit | fixed | Unit coverage: targeted vitest passed: tests/shared-home-empty-state.dom.test.tsx (17 passed). PR required still blocked on pre-existing check failure at old remote head before sync. | | 2026-08-12 | codex/pr-workflow-safety-230-296 | bc0a491fdf4146775629f9b2b03e2a2cc61bd7cb | pr-1830 unblock | unblocked: merged origin/main (outstanding-issues conflict), PR body RAG impact + governance, resolved Copilot thread; merge-tree clean; required CI in progress | check:outstanding-issues pass; evaluatePullRequestPolicy ok; merge-tree clean; PR policy/mergeability/Change scope in progress | +| 2026-08-12 | work | bc00419f83de1a59d9413b9ecf0827775c83e070 | recent important regression audit and prevention fixes | two confirmed regressions restored; no other high-confidence unresolved loss found | focused Vitest 27 passed; format changed passed; PR-local reached full unit suite then timed out | diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 651a4cb0c3..84247b396a 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -877,6 +877,7 @@ function GlobalStandaloneSearchShellBody({ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} + showPhoneSuggestionTickerOnHome={isStandaloneModeHome || pathname === "/"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} desktopPageComposerSlotId={ diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 1cc6aa23e3..cf419dc0f8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -195,6 +195,7 @@ export function MasterSearchHeader({ mobileBottomSearchVariant = "default", desktopSearchPlacement = "default", searchComposerVisible = true, + showPhoneSuggestionTickerOnHome = false, desktopHomeComposerSlotId, desktopPageComposerSlotId, heroComposerBreakpoint = "all", @@ -250,6 +251,8 @@ export function MasterSearchHeader({ * content keeps maximum screen space. Every phone dock uses it now; the * "default" value remains for hosts that need the taller legacy dock. */ mobileBottomSearchVariant?: "default" | "compact"; + /** Show the compact, tappable suggestion ticker only for standalone-mode homes. */ + showPhoneSuggestionTickerOnHome?: boolean; desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; /** Mode-home slot the composer portals into so the search pill sits in the @@ -1816,6 +1819,7 @@ export function MasterSearchHeader({ onListboxIdReady={setCommandListboxId} onActiveItemIdChange={setCommandActiveItemId} onFocusSearchInput={handleFocusSearchInput} + showPhoneSuggestionTicker={showPhoneSuggestionTickerOnHome} >
void; +}) { const [activeExampleIndex, setActiveExampleIndex] = useState(0); + const [heldTickerExample, setHeldTickerExample] = useState(null); + const [isTickerHeld, setIsTickerHeld] = useState(false); const activeExample = examples[activeExampleIndex % examples.length]; useEffect(() => { + if (isTickerHeld) return; if (examples.length <= 1) return; const intervalId = window.setInterval(() => { setActiveExampleIndex((current) => (current + 1) % examples.length); }, SMART_HINT_ROTATION_MS); return () => window.clearInterval(intervalId); - }, [examples]); + }, [examples, isTickerHeld]); + + const freezeTicker = useCallback(() => { + setHeldTickerExample(activeExample); + setIsTickerHeld(true); + }, [activeExample]); + const releaseTicker = useCallback(() => setIsTickerHeld(false), []); + const resolvedTickerExample = isTickerHeld ? (heldTickerExample ?? activeExample) : activeExample; if (!activeExample) return null; return ( -
- Smart search - - - Try “{activeExample}” in {modeLabel}. - -
+ <> +
+ Smart search + + + Try “{activeExample}” in {modeLabel}. + +
+ {showPhoneTicker ? ( + + ) : null} + ); } @@ -364,6 +423,7 @@ export function UniversalSearchCommandSurface({ onFocusSearchInput, onListboxIdReady, onActiveItemIdChange, + showPhoneSuggestionTicker = false, placement = "inline", children, }: { @@ -384,6 +444,8 @@ export function UniversalSearchCommandSurface({ onFocusSearchInput?: () => void; onListboxIdReady?: (listboxId: string) => void; onActiveItemIdChange?: (activeItemId: string | null) => void; + /** Show the compact, tappable suggestion ticker below an in-flow phone home composer. */ + showPhoneSuggestionTicker?: boolean; placement?: CommandSurfacePlacement; children: ReactNode; }) { @@ -992,7 +1054,15 @@ export function UniversalSearchCommandSurface({ placement === "bottom-dock" ? "gap-1" : "gap-2", )} > - + { + onQueryChange(example); + onFocusSearchInput?.(); + }} + />
{ diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index b06a1daa1c..6f75bb7367 100644 --- a/src/lib/catalog-search.ts +++ b/src/lib/catalog-search.ts @@ -24,6 +24,60 @@ export function compactSearchText(value: string) { return value.replace(/\s+/g, ""); } +function typoDistanceLimit(term: string) { + if (term.length >= 8) return 2; + // Four-character clinical abbreviations (SSRI/SNRI, ADHD/ODD, etc.) are + // often one edit apart; require five characters before allowing a typo. + if (term.length >= 5) return 1; + return 0; +} + +/** Bounded Damerau-Levenshtein distance for conservative catalogue typo recovery. */ +function boundedTypoDistance(left: string, right: string, limit: number) { + if (Math.abs(left.length - right.length) > limit) return limit + 1; + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + let previousPrevious: number[] | undefined; + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + const current = [leftIndex]; + let rowMinimum = current[0]; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; + let distance = Math.min( + current[rightIndex - 1] + 1, + previous[rightIndex] + 1, + previous[rightIndex - 1] + substitutionCost, + ); + if ( + previousPrevious && + leftIndex > 1 && + rightIndex > 1 && + left[leftIndex - 1] === right[rightIndex - 2] && + left[leftIndex - 2] === right[rightIndex - 1] + ) { + distance = Math.min(distance, previousPrevious[rightIndex - 2] + 1); + } + current[rightIndex] = distance; + rowMinimum = Math.min(rowMinimum, distance); + } + if (rowMinimum > limit) return limit + 1; + previousPrevious = previous.slice(); + previous.splice(0, previous.length, ...current); + } + return previous[right.length]; +} + +/** Number of query tokens with a conservative near-word match in normalized text. */ +export function fuzzySearchTokenCount(query: string, text: string) { + const queryTokens = normalizeSearchText(query).split(/\s+/).filter(Boolean); + const words = Array.from(new Set(normalizeSearchText(text).split(/\s+/).filter(Boolean))); + return queryTokens.filter((term) => { + if (words.some((word) => word.includes(term))) return false; + const limit = typoDistanceLimit(term); + return limit > 0 && words.some((word) => boundedTypoDistance(term, word, limit) <= limit); + }).length; +} + export type CatalogField = { // Wrapper-facing key used to build human-readable reasons (e.g. "title", "contact"). id: string; @@ -39,6 +93,7 @@ export type CatalogMatchSignals = { // Matched term count for terms introduced by expandTokens (e.g. symptom // aliases) that were not part of the raw query. expanded: number; + fuzzy: number; compact: boolean; phrase: boolean; prefix: boolean; @@ -130,6 +185,9 @@ export function rankCatalogRecords( score += content * contentWeight; const expanded = expandedTerms.filter((term) => text.includes(term)).length; + const fuzzy = score === 0 ? fuzzySearchTokenCount(normalizedQuery, text) : 0; + // Fuzzy evidence rescues misspellings but stays weaker than literal content. + score += fuzzy * Math.max(1, contentWeight * 0.5); const compact = compactBonus > 0 && @@ -158,7 +216,17 @@ export function rankCatalogRecords( record, index, score, - signals: { fields, content, expanded, compact, phrase, prefix, exact, broad } satisfies CatalogMatchSignals, + signals: { + fields, + content, + expanded, + fuzzy, + compact, + phrase, + prefix, + exact, + broad, + } satisfies CatalogMatchSignals, }; }) .filter((match) => match.score > 0) diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 09cb7e0ebc..9d7ee9b744 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -24,8 +24,19 @@ function sourceSegment(contents: string, startMarker: string, endMarker: string) const clinicalDashboardSource = source("src/components/ClinicalDashboard.tsx"); const masterSearchHeaderSource = source("src/components/clinical-dashboard/master-search-header.tsx"); const universalAlsoMatchesSource = source("src/components/clinical-dashboard/universal-search-also-matches.tsx"); +const universalCommandSurfaceSource = source("src/components/clinical-dashboard/universal-search-command-surface.tsx"); +const globalSearchShellSource = source("src/components/clinical-dashboard/global-search-shell.tsx"); describe("audit navigation and auth regressions", () => { + it("keeps the tappable phone suggestion ticker connected to standalone homes", () => { + expect(globalSearchShellSource).toContain( + 'showPhoneSuggestionTickerOnHome={isStandaloneModeHome || pathname === "/"}', + ); + expect(masterSearchHeaderSource).toContain("showPhoneSuggestionTicker={showPhoneSuggestionTickerOnHome}"); + expect(universalCommandSurfaceSource).toContain('data-testid="smart-search-phone-ticker"'); + expect(universalCommandSurfaceSource).toContain("onClick={() => onPickExample(resolvedTickerExample)}"); + expect(universalCommandSurfaceSource).toContain("onQueryChange(example);"); + }); it("redirects exact legacy route handlers at request time while retaining useful query state", () => { const applications = redirectApplications( new NextRequest("https://clinical-kb.test/applications?q=acute+care&tag=one&tag=two"), diff --git a/tests/catalog-search.test.ts b/tests/catalog-search.test.ts index c788eff509..e12489aa5b 100644 --- a/tests/catalog-search.test.ts +++ b/tests/catalog-search.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { compactSearchText, normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search"; +import { + compactSearchText, + fuzzySearchTokenCount, + normalizeSearchText, + rankCatalogRecords, +} from "../src/lib/catalog-search"; type Item = { title: string; slug: string; tags: string[]; body: string }; @@ -56,6 +61,18 @@ describe("rankCatalogRecords", () => { expect(results.some((match) => match.record.slug === "lithium-levels")).toBe(false); }); + it("finds close catalogue words after an insertion, omission, or transposition", () => { + expect(rank("clozpaine")[0]?.record.slug).toBe("clozapine-monitoring"); + expect(rank("lithum")[0]?.record.slug).toBe("lithium-levels"); + expect(fuzzySearchTokenCount("monitroing", "Clozapine monitoring guidance")).toBe(1); + }); + + it("does not fuzz short clinical abbreviations or unrelated words", () => { + expect(fuzzySearchTokenCount("GAD", "Major depressive disorder")).toBe(0); + expect(fuzzySearchTokenCount("SSRI", "SNRI")).toBe(0); + expect(rank("transport").some((match) => match.record.slug === "clozapine-monitoring")).toBe(false); + }); + it("applies the whole-phrase bonus on top of term matches", () => { const [top] = rank("clozapine monitoring"); // 2 title terms (12) + 2 content terms (4) + phrase (4). diff --git a/tests/ui-overlap.spec.ts b/tests/ui-overlap.spec.ts index 79923b3f6a..b2e4008265 100644 --- a/tests/ui-overlap.spec.ts +++ b/tests/ui-overlap.spec.ts @@ -266,12 +266,17 @@ test.describe("Header element overlap coverage", () => { ); }); - test("phone smart search does not show the desktop rotating text or prompt row", async ({ page }) => { + test("phone smart search shows the tappable suggestion ticker instead of desktop hints", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoDashboard(page); await gotoHome(page); await expect(page.getByTestId("smart-search-rotating-text")).toBeHidden(); await expect(page.getByTestId("smart-search-prompt-row")).toBeHidden(); + const ticker = page.getByTestId("smart-search-phone-ticker"); + await expect(ticker).toBeVisible(); + const suggestedQuery = await ticker.locator(".smart-search-phone-ticker-query").textContent(); + await ticker.click(); + await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toHaveValue(suggestedQuery ?? ""); }); }); From 95855174b4f7a9951fb6a81529698b55f1348356 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:44:17 +0800 Subject: [PATCH 02/10] fix(search): address ticker and fuzzy ranking review --- docs/branch-review-ledger.md | 1 + .../global-search-shell.tsx | 2 +- .../universal-search-command-surface.tsx | 7 ++- src/lib/catalog-search.ts | 45 ++++++++++++------- .../audit-navigation-auth-regressions.test.ts | 5 +-- tests/catalog-search.test.ts | 13 ++++++ tests/dsm.test.ts | 4 ++ tests/ui-overlap.spec.ts | 4 ++ 8 files changed, 61 insertions(+), 20 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 7f30439f72..a87978ab11 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -901,3 +901,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-12 | 1815 | 27ce96e1755055ceee2eeae02d6efdf11259fcde | babysit | fixed | Unit coverage: targeted vitest passed: tests/shared-home-empty-state.dom.test.tsx (17 passed). PR required still blocked on pre-existing check failure at old remote head before sync. | | 2026-08-12 | codex/pr-workflow-safety-230-296 | bc0a491fdf4146775629f9b2b03e2a2cc61bd7cb | pr-1830 unblock | unblocked: merged origin/main (outstanding-issues conflict), PR body RAG impact + governance, resolved Copilot thread; merge-tree clean; required CI in progress | check:outstanding-issues pass; evaluatePullRequestPolicy ok; merge-tree clean; PR policy/mergeability/Change scope in progress | | 2026-08-12 | work | bc00419f83de1a59d9413b9ecf0827775c83e070 | recent important regression audit and prevention fixes | two confirmed regressions restored; no other high-confidence unresolved loss found | focused Vitest 27 passed; format changed passed; PR-local reached full unit suite then timed out | +| 2026-08-12 | origin/pr/1851 | 82b71b3f4a0b81bd78253be726e66d6fe98c829c | PR #1851 full diff vs origin/main | P1 fuzzy ranking and submitted ticker plus stale hold fixed | focused catalog DSM wiring and Chromium pending coordinator | diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 84247b396a..f07225ded2 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -877,7 +877,7 @@ function GlobalStandaloneSearchShellBody({ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} - showPhoneSuggestionTickerOnHome={isStandaloneModeHome || pathname === "/"} + showPhoneSuggestionTickerOnHome={isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} desktopPageComposerSlotId={ diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 87c164c135..86c05f0fd6 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -198,7 +198,12 @@ function SmartRotatingHint({ setIsTickerHeld(true); }, [activeExample]); const releaseTicker = useCallback(() => setIsTickerHeld(false), []); - const resolvedTickerExample = isTickerHeld ? (heldTickerExample ?? activeExample) : activeExample; + // A mode change can replace the examples while a pointer/focus hold is + // active. Never keep displaying or submit a held value that is no longer in + // the current mode's suggestion set. + const currentHeldTickerExample = + heldTickerExample && examples.includes(heldTickerExample) ? heldTickerExample : activeExample; + const resolvedTickerExample = isTickerHeld ? currentHeldTickerExample : activeExample; if (!activeExample) return null; diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index 6f75bb7367..7e29a0e342 100644 --- a/src/lib/catalog-search.ts +++ b/src/lib/catalog-search.ts @@ -68,14 +68,18 @@ function boundedTypoDistance(left: string, right: string, limit: number) { } /** Number of query tokens with a conservative near-word match in normalized text. */ -export function fuzzySearchTokenCount(query: string, text: string) { +function fuzzySearchTokens(query: string, text: string) { const queryTokens = normalizeSearchText(query).split(/\s+/).filter(Boolean); const words = Array.from(new Set(normalizeSearchText(text).split(/\s+/).filter(Boolean))); return queryTokens.filter((term) => { if (words.some((word) => word.includes(term))) return false; const limit = typoDistanceLimit(term); return limit > 0 && words.some((word) => boundedTypoDistance(term, word, limit) <= limit); - }).length; + }); +} + +export function fuzzySearchTokenCount(query: string, text: string) { + return fuzzySearchTokens(query, text).length; } export type CatalogField = { @@ -168,6 +172,14 @@ export function rankCatalogRecords( const text = options.fullText(record); const fields: Record = {}; let score = 0; + let fuzzy = 0; + const compact = + compactBonus > 0 && + compactQuery.length >= compactMinLength && + (compactSearchText(text).includes(compactQuery) || + (options.compactExtraText + ? compactSearchText(options.compactExtraText(record)).includes(compactQuery) + : false)); for (const field of options.fields) { const haystack = field.text(record); @@ -176,26 +188,29 @@ export function rankCatalogRecords( // align with a word boundary — substring hits ("renal" inside // "adrenaline") stay confined to the low-weight content haystack. const matched = terms.filter((term) => matchesTermAtWordBoundary(haystack, term)).length; - if (!matched) continue; - fields[field.id] = matched; - score += matched * field.weight; + if (matched) { + fields[field.id] = matched; + score += matched * field.weight; + } + + // A typo in a title/name/code field must retain that field's weight. + // Otherwise an intended record ties incidental full-text mentions and + // a limited universal-search result can omit the best match entirely. + const fuzzyFieldMatches = compact ? 0 : fuzzySearchTokenCount(normalizedQuery, haystack); + fuzzy += fuzzyFieldMatches; + score += fuzzyFieldMatches * field.weight; } const content = terms.filter((term) => text.includes(term)).length; score += content * contentWeight; const expanded = expandedTerms.filter((term) => text.includes(term)).length; - const fuzzy = score === 0 ? fuzzySearchTokenCount(normalizedQuery, text) : 0; - // Fuzzy evidence rescues misspellings but stays weaker than literal content. - score += fuzzy * Math.max(1, contentWeight * 0.5); + const fuzzyContentFallback = !compact && fuzzy === 0 ? fuzzySearchTokenCount(normalizedQuery, text) : 0; + fuzzy += fuzzyContentFallback; + // Broad full-text fuzzy evidence is only a fallback and stays weaker than + // literal content or a weighted field match. + score += fuzzyContentFallback * Math.max(1, contentWeight * 0.5); - const compact = - compactBonus > 0 && - compactQuery.length >= compactMinLength && - (compactSearchText(text).includes(compactQuery) || - (options.compactExtraText - ? compactSearchText(options.compactExtraText(record)).includes(compactQuery) - : false)); if (compact) score += compactBonus; const phrase = phraseBonus > 0 && text.includes(normalizedQuery); diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 9d7ee9b744..4bef66f6d3 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -29,12 +29,11 @@ const globalSearchShellSource = source("src/components/clinical-dashboard/global describe("audit navigation and auth regressions", () => { it("keeps the tappable phone suggestion ticker connected to standalone homes", () => { - expect(globalSearchShellSource).toContain( - 'showPhoneSuggestionTickerOnHome={isStandaloneModeHome || pathname === "/"}', - ); + expect(globalSearchShellSource).toContain('isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)'); expect(masterSearchHeaderSource).toContain("showPhoneSuggestionTicker={showPhoneSuggestionTickerOnHome}"); expect(universalCommandSurfaceSource).toContain('data-testid="smart-search-phone-ticker"'); expect(universalCommandSurfaceSource).toContain("onClick={() => onPickExample(resolvedTickerExample)}"); + expect(universalCommandSurfaceSource).toContain("examples.includes(heldTickerExample)"); expect(universalCommandSurfaceSource).toContain("onQueryChange(example);"); }); it("redirects exact legacy route handlers at request time while retaining useful query state", () => { diff --git a/tests/catalog-search.test.ts b/tests/catalog-search.test.ts index e12489aa5b..d6bb1b74f5 100644 --- a/tests/catalog-search.test.ts +++ b/tests/catalog-search.test.ts @@ -67,6 +67,19 @@ describe("rankCatalogRecords", () => { expect(fuzzySearchTokenCount("monitroing", "Clozapine monitoring guidance")).toBe(1); }); + it("weights a fuzzy title above an incidental full-text mention", () => { + const records = [ + { title: "Schizophrenia", body: "Core diagnostic record" }, + { title: "Other condition", body: "Consider schizophrenia in the differential" }, + ]; + const results = rankCatalogRecords(records, "schizophrnia", { + fields: [{ id: "title", weight: 8, text: (record) => normalizeSearchText(record.title) }], + fullText: (record) => normalizeSearchText(`${record.title} ${record.body}`), + }); + + expect(results[0]?.record.title).toBe("Schizophrenia"); + }); + it("does not fuzz short clinical abbreviations or unrelated words", () => { expect(fuzzySearchTokenCount("GAD", "Major depressive disorder")).toBe(0); expect(fuzzySearchTokenCount("SSRI", "SNRI")).toBe(0); diff --git a/tests/dsm.test.ts b/tests/dsm.test.ts index 5f94a6ad6b..f6d975366e 100644 --- a/tests/dsm.test.ts +++ b/tests/dsm.test.ts @@ -44,6 +44,10 @@ describe("DSM clinical catalogue", () => { ).toBe(true); }); + it("keeps a typo-corrected diagnosis title ahead of incidental clinical mentions", () => { + expect(rankDsmDiagnoses("schizophrnia", 5).some((match) => match.diagnosis.slug === "schizophrenia")).toBe(true); + }); + it("links named differential considerations back to catalogue records", () => { const diagnosis = getDsmDiagnosis("major-depressive-disorder"); const bipolar = diagnosis?.differentials.find((item) => item.startsWith("Bipolar I or II")); diff --git a/tests/ui-overlap.spec.ts b/tests/ui-overlap.spec.ts index b2e4008265..01b1977343 100644 --- a/tests/ui-overlap.spec.ts +++ b/tests/ui-overlap.spec.ts @@ -278,5 +278,9 @@ test.describe("Header element overlap coverage", () => { const suggestedQuery = await ticker.locator(".smart-search-phone-ticker-query").textContent(); await ticker.click(); await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toHaveValue(suggestedQuery ?? ""); + + await page.goto("/?mode=answer&q=lithium&run=1", { waitUntil: "domcontentloaded" }); + await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toHaveValue("lithium"); + await expect(page.getByTestId("smart-search-phone-ticker")).toBeHidden(); }); }); From 07481f99f0010d7229b2f74618037a9060b6897a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:59:20 +0800 Subject: [PATCH 03/10] test(search): run submitted-root ticker regression in critical CI --- tests/ui-phone-scroll-submitted-root.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui-phone-scroll-submitted-root.spec.ts b/tests/ui-phone-scroll-submitted-root.spec.ts index 3860af1b52..52658e9198 100644 --- a/tests/ui-phone-scroll-submitted-root.spec.ts +++ b/tests/ui-phone-scroll-submitted-root.spec.ts @@ -43,7 +43,7 @@ async function mockDemoDashboard(page: Page) { }); } -test("submitted root search keeps its query and hides the phone suggestion ticker", async ({ page }) => { +test("submitted root search keeps its query and hides the phone suggestion ticker @critical", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoDashboard(page); await page.goto("/?mode=answer&q=lithium&run=1", { waitUntil: "domcontentloaded" }); From a5ee6293ae6fdbe0387a4346f8ae9872513defa8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:06:07 +0800 Subject: [PATCH 04/10] test(search): require fuzzy field reason signals --- tests/catalog-search.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/catalog-search.test.ts b/tests/catalog-search.test.ts index d6bb1b74f5..ed278ecf11 100644 --- a/tests/catalog-search.test.ts +++ b/tests/catalog-search.test.ts @@ -67,7 +67,7 @@ describe("rankCatalogRecords", () => { expect(fuzzySearchTokenCount("monitroing", "Clozapine monitoring guidance")).toBe(1); }); - it("weights a fuzzy title above an incidental full-text mention", () => { + it("weights and reports a fuzzy title above an incidental full-text mention", () => { const records = [ { title: "Schizophrenia", body: "Core diagnostic record" }, { title: "Other condition", body: "Consider schizophrenia in the differential" }, @@ -78,6 +78,8 @@ describe("rankCatalogRecords", () => { }); expect(results[0]?.record.title).toBe("Schizophrenia"); + expect(results[0]?.signals.fuzzy).toBe(1); + expect(results[0]?.signals.fields.title).toBe(1); }); it("does not fuzz short clinical abbreviations or unrelated words", () => { From 7206fe71607ec5797b533bc49a6477abad36dc8c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:08:24 +0800 Subject: [PATCH 05/10] fix(search): expose fuzzy field match reasons --- src/lib/catalog-search.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index 7e29a0e342..bbd16959d5 100644 --- a/src/lib/catalog-search.ts +++ b/src/lib/catalog-search.ts @@ -90,7 +90,8 @@ export type CatalogField = { }; export type CatalogMatchSignals = { - // Matched term count per field id (only fields with at least one match are present). + // Literal or conservative fuzzy matched term count per field id + // (only fields with at least one match are present). fields: Record; // Matched term count against the full-text haystack. content: number; @@ -197,6 +198,9 @@ export function rankCatalogRecords( // Otherwise an intended record ties incidental full-text mentions and // a limited universal-search result can omit the best match entirely. const fuzzyFieldMatches = compact ? 0 : fuzzySearchTokenCount(normalizedQuery, haystack); + if (fuzzyFieldMatches) { + fields[field.id] = (fields[field.id] ?? 0) + fuzzyFieldMatches; + } fuzzy += fuzzyFieldMatches; score += fuzzyFieldMatches * field.weight; } From d8773765574ae23383808f514dfb73d19e90064f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:19:44 +0800 Subject: [PATCH 06/10] fix(ci): register submitted-root regression in UI shards --- scripts/playwright-pr-shards.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index 0dc51a4cb3..38c438fb17 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -52,6 +52,8 @@ export const prUiSpecProfiles = Object.freeze([ { file: "tests/ui-visual-artifacts.spec.ts", shard: 3, fullSeconds: 2.7, criticalSeconds: 0 }, { file: "tests/ui-forms-section-nav.spec.ts", shard: 3, fullSeconds: 2.5, criticalSeconds: 0 }, { file: "tests/ui-therapy-nav-scroll.spec.ts", shard: 3, fullSeconds: 1.9, criticalSeconds: 0 }, + // Critical-only regression; use a conservative estimate until the next hosted timing sample. + { file: "tests/ui-phone-scroll-submitted-root.spec.ts", shard: 3, fullSeconds: 1.0, criticalSeconds: 1.0 }, // Skipped in the sampled runner because pdf.js could not raster there. { file: "tests/ui-document-canvas.spec.ts", shard: 3, fullSeconds: 0, criticalSeconds: 0 }, ]); From 29e50d036d10c011ad56acafa9399b432e43e401 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:33:54 +0800 Subject: [PATCH 07/10] fix(search): bound long-token typo recovery to one edit --- src/lib/catalog-search.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index bbd16959d5..061aeeab51 100644 --- a/src/lib/catalog-search.ts +++ b/src/lib/catalog-search.ts @@ -25,10 +25,12 @@ export function compactSearchText(value: string) { } function typoDistanceLimit(term: string) { - if (term.length >= 8) return 2; + // One edit recovers common clinical typos without allowing exact long drug + // names to cross-match distinct catalogue entries (for example fluoxetine + // and duloxetine, or prednisone and prednisolone). + if (term.length >= 5) return 1; // Four-character clinical abbreviations (SSRI/SNRI, ADHD/ODD, etc.) are // often one edit apart; require five characters before allowing a typo. - if (term.length >= 5) return 1; return 0; } From 2b9891a0355da1ad4562432a98d654bf86633c12 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:34:20 +0800 Subject: [PATCH 08/10] test(search): prevent cross-drug fuzzy matches --- ...atalog-search-drug-name-regression.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/catalog-search-drug-name-regression.test.ts diff --git a/tests/catalog-search-drug-name-regression.test.ts b/tests/catalog-search-drug-name-regression.test.ts new file mode 100644 index 0000000000..3620bf52bd --- /dev/null +++ b/tests/catalog-search-drug-name-regression.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search"; + +type Drug = { name: string }; + +const drugs: Drug[] = [ + { name: "Fluoxetine" }, + { name: "Duloxetine" }, + { name: "Prednisone" }, + { name: "Prednisolone" }, +]; + +function search(query: string) { + return rankCatalogRecords(drugs, query, { + fields: [{ id: "name", weight: 8, text: (drug) => normalizeSearchText(drug.name) }], + fullText: (drug) => normalizeSearchText(drug.name), + exactValues: (drug) => [normalizeSearchText(drug.name)], + exactBonus: 10, + }); +} + +describe("catalogue drug-name typo recovery", () => { + it("does not fuzzy-match a distinct long medication name beside an exact match", () => { + expect(search("fluoxetine").map((match) => match.record.name)).toEqual(["Fluoxetine"]); + expect(search("prednisone").map((match) => match.record.name)).toEqual(["Prednisone"]); + }); + + it("still recovers a single-edit typo in a long medication name", () => { + expect(search("fluoxetne")[0]?.record.name).toBe("Fluoxetine"); + expect(search("prednisne")[0]?.record.name).toBe("Prednisone"); + }); +}); From d2d3256ea9689a130d4dc466ae17122556287986 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:22:34 +0000 Subject: [PATCH 09/10] Add wrong-drug exclusion regression test for the fuzzy typo cap Pins ledger #310: with both the exact drug and its two-edit neighbour in the catalogue, fluoxetine must not surface duloxetine and prednisone must not surface prednisolone, while single-edit typo recovery (setraline->sertraline) keeps working. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WBdo125Dh3idPcF7CmCCmG --- tests/catalog-search.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/catalog-search.test.ts b/tests/catalog-search.test.ts index ed278ecf11..897bb2318b 100644 --- a/tests/catalog-search.test.ts +++ b/tests/catalog-search.test.ts @@ -88,6 +88,35 @@ describe("rankCatalogRecords", () => { expect(rank("transport").some((match) => match.record.slug === "clozapine-monitoring")).toBe(false); }); + it("never cross-matches a distinct drug two edits away, even with both records present", () => { + // Ledger #310: with a >=8-char / 2-edit tier, fluoxetine matched duloxetine (substitute + // f->d + transpose lu->ul counts as 2 Damerau edits) and prednisone matched prednisolone. + // The 1-edit cap must exclude the wrong drug while the exact drug still ranks, and while + // genuine single-edit typo recovery keeps working. + const drugs = [ + { title: "Fluoxetine", body: "SSRI dosing" }, + { title: "Duloxetine", body: "SNRI dosing" }, + { title: "Prednisone", body: "Corticosteroid taper" }, + { title: "Prednisolone", body: "Corticosteroid taper" }, + { title: "Sertraline", body: "SSRI dosing" }, + ]; + const rankDrugs = (query: string) => + rankCatalogRecords(drugs, query, { + fields: [{ id: "title", weight: 8, text: (record) => normalizeSearchText(record.title) }], + fullText: (record) => normalizeSearchText(record.title), + }); + + const fluoxetine = rankDrugs("fluoxetine"); + expect(fluoxetine[0]?.record.title).toBe("Fluoxetine"); + expect(fluoxetine.some((match) => match.record.title === "Duloxetine")).toBe(false); + + const prednisone = rankDrugs("prednisone"); + expect(prednisone[0]?.record.title).toBe("Prednisone"); + expect(prednisone.some((match) => match.record.title === "Prednisolone")).toBe(false); + + expect(rankDrugs("setraline")[0]?.record.title).toBe("Sertraline"); + }); + it("applies the whole-phrase bonus on top of term matches", () => { const [top] = rank("clozapine monitoring"); // 2 title terms (12) + 2 content terms (4) + phrase (4). From 488d57ac56ab04746c80322f8f97cf28eb955430 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:47:52 +0800 Subject: [PATCH 10/10] style(tests): format merged navigation assertions --- tests/audit-navigation-auth-regressions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 213db16b7e..15eac0c271 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -294,4 +294,3 @@ describe("audit navigation and auth regressions", () => { ); }); }); -