diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index e261c05c7f..171e63bcdb 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -840,7 +840,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | claude/document-viewer-phase-3-bj5k5v | 156db63f1b60f09791e426b043ea90d427b789ab | post-#1772 test simplification: replace the viewer perf source-text grep with behavioural coverage; de-literalise rail window and keyboard label assertions | PR #1777 opened. Self-review of #1772's own tests against an excessive-strictness challenge. Finding: the client-performance-boundaries grep for resolveLiveCanvasWindow / resolveRenderAheadPages / liveCanvasLimit / requestIdleCallback was not merely brittle, it was INEFFECTIVE - replacing the budget call with a hardcoded 3 leaves every identifier in the file, so it stayed green while the viewer retained three full-zoom canvases (measured both ways). Replaced by a DOM case that binds the budget (VIEWER_MAX_ZOOM at dpr 3 gives ~16.8M backing px against the 24M budget, window collapses to 1) and fails on exactly that substitution. Also exported RAIL_IMAGE_WINDOW so the rail test derives its counts (verified by tuning 6->8: all 7 still pass), and relaxed the keyboard aria-label assertions from exact prose to the key names. Pre-existing greps for disableAutoFetch / canvas.width = 0 / pageToCleanup left alone deliberately - two are now redundant but they are another author's guard. | verify:pr-local (1 pre-existing root-only failure: pr-handoff-stop #291; 5872 passed), build OK 80s + client bundle secret check, eval:rag:offline 36 golden cases / 574 tests, lint + typecheck clean. Sabotage-verified in both directions. Browser gates unrunnable here (#279) - unchanged by this diff. | | 2026-08-09 | claude/planning-build-intelligence-9ot0nm | 3df3cb3993f73cda4dbbc4ac7549f84b3c6ea7ed | Node 24.15 engine floor: engines.node, preinstall hook, check:runtime, session-start provisioning, codex-cloud assertion | Authored and handed off as PR #1771; closes #285; operationalRisk true, clinicalRisk/ragRanking false | test 5800 passed/1 pre-existing root-uid failure (pr-handoff-stop, confirmed on stashed clean tree); lint 0; typecheck 0; prettier --check . pass; check:runtime pass; check:codex-cloud pass; check:outstanding-issues pass; preinstall boundary proof 24.13/24.14.9 reject, 24.15/24.19 accept, 25.0.0 reject; contract test mutation-checked red | | 2026-08-09 | pull/1771 | 466ec4216272c31c5f754db213dbdc529583b167 | PR 1771 runtime floor enforcement | P2: Cloud and Desktop setup paths remain major-only; do not merge until range-aware | static review; check:runtime PASS; check:codex-cloud PASS; ledger PASS; outstanding issues PASS; focused Vitest blocked by active Playwright lease | -| 2026-08-09 | PR #1782 / cursor/fix-document-open-scroll-e5bf | 5709f2cc7a954197e02107c96d7896d8d13445c3 | document-viewer open-at-top | ship: remove chunk mount scrollIntoView so document opens stay at overview top | document-viewer-shell.dom 7 pass; document-section-summary.dom 8 pass; verify:pr-local dry-run | +| 2026-08-09 | cursor/therapy-card-densify-e975 | 3db839a6bb1f5b45fc55bb732d21b30551a506b0 | therapy search ResultCard densify (gap, tags, favourite, actions, match cells) | pass — denser cards; band gap fixed; single-row prioritized tags; heart top-right; 3-col actions; summarised cells | unit 35/35; verify:pr-local pass; ensure visual phone+desktop pass | | 2026-08-09 | claude/m3-token-debt-262-261 | c6e1fe7fc42ec6f286eb5a3d8f7ddad7dfad2724 | design-system contract: raw padding/radius/line-height ratchets + type-step selection gate (#262 parts 2/3); closed #218/#270 | Authored and self-verified; PR #1780 open, auto-merge deliberately not armed (gate change). Baseline additive: all 15 pre-existing metrics and every debtByPath entry byte-identical; 94/94 new findings verified present at their cited line. Mutation-tested both halves of part 3 and three failure modes of part 2. | check:design-system-contract, check:icon-scale, check:type-scale, check:outstanding-issues, vitest design-system-contract-utils (31 passed), format:check whole-tree, verify:cheap (exit 1 from 5 pre-existing failures, none in this diff; 3 cleared by merging main, remaining 2 byte-identical to origin/main) | | 2026-08-09 | claude/documentviewer-nav-convergence-oddhjx | 1395d533cb13eadc705e47f76aa9f39a7a11c058 | DocumentViewer / in-page-nav convergence (#288): non-adoption decision recorded in docs/search-chrome-behaviour.md; merged duplicated visible-element predicate into resolveVisibleElement; new convergence guard test | Converged what was duplicated; DocumentReviewer header adoption declined on the merits with four blocking reasons recorded. No contract test edited. | verify:pr-local (546/547 files, 5883 tests pass; sole failure tests/pr-handoff-stop.test.ts reproduced on pristine origin/main), verify:phone-chrome (contracts 123 pass; focused Chromium 7 pass), contract set 12 files/151 tests pass, lint, typecheck, format | | 2026-08-09 | claude/disabled-button-accessibility-piclvr | 722abdb780c715c0a89df268ed48f6c741ffd569 | disabled-placeholder buttons -> aria-disabled + inert handler (25 sites, 13 components); controlDisabled/therapy recipe aria-disabled styling; require-button-wiring redundantDisabledPair gate; wiring-conventions contract rewrite (settles #291) | authored — PR #1778 opened | lint (uncached, exit 0); typecheck; test 5878 passed/1 pre-existing root-env failure in pr-handoff-stop; build; check:rag:fixtures 36 golden cases; prettier --check clean; verify:ui not run (no browser in container) | @@ -850,6 +850,8 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | claude/m3-token-debt-262-261 | 7bac3bd762b381cb25c9b2a15ef3bb7223d15b16 | PR #1780 review-and-fix | fixed P2 ratchet bypasses (arbitrary-property classes, CSS-consumer exemption anti-rot, modern CSS zero units); Bugbot clean; merge-tree clean; required CI was green on prior tip | vitest design-system-contract-utils 32/32; check:design-system-contract; mutation CSS-exemption fail→restore; verify:cheap PASS (549 files / 5933 tests); verify:pr-local stages PASS (test flake in design-system-adoption timed out once then 51/51 + full test 549/549 + check:rag:fixtures PASS); no provider gates | | 2026-08-09 | cursor/dsm-search-header-fix-15d6 | df088c766f1761496189ec09146aa54c23b1c012 | dsm-search-header | pass: removed catalogue page strip; ribbon + category filter match target | vitest dsm-search-empty-state; npm test 5857 passed; lint; typecheck; ensure phone /dsm/search?q=Delirium | | 2026-08-09 | claude/m3-token-debt-262-261 | fe75e6acade008e68f953e235cc035f2e5d9d216 | PR #1780 review-and-fix | fixed P2 ratchet bypasses; synced origin/main (#1775); Bugbot clean; merge-tree clean | vitest design-system-contract-utils 32/32; check:design-system-contract; mutation CSS-exemption; verify:cheap PASS 549/5933; verify:pr-local stages PASS after adoption flake retest; check:rag:fixtures PASS; no provider gates | -| 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 98029875db7d640d3e699829249bb33892296bff | PR #1782 unblock | before: static-pr+coverage failed on stale adoption-manifest (document-viewer-shell testFiles drift), merge-tree clean 0 behind, auto-merge armed, 1 advisory CodeRabbit waitFor thread; after: regenerated adoption-manifest, hardened scroll negative assertion, pre-commit+handoff adoption sync to prevent recurrence; CodeRabbit dispositioned as fixed by sync assert | check:design-system-adoption PASS; vitest design-system-adoption+document-viewer-shell+docs-inventory 63/63 PASS; format; no provider-backed checks | +| 2026-08-09 | cursor/therapy-card-densify-e975 | 52f07d49f89e6c786c624ccbd38ae552818a2071 | PR 1783 babysit | fixed review threads: TagRow +N clip, title/alias preview exclusion, preview field fallbacks; Copilot md grid kept; CI re-triggered after Copilot tip | npm test: 5958 passed / 4 skipped | | 2026-08-09 | claude/in-page-nav-pr-3-i6gi8n | 6651feef4fab63f1181fba57908cb22e2932df3c | in-page-nav PR 3: convert /medications/[slug] (panel-swap) and /factsheets/[slug] (anchors) onto InPageNavHeader; record the differentials-presentations exception; delete orphaned SecondaryNavigation (#271) | converted 2 of 3 routes, 3rd recorded as a reasoned lasting exception; tocFor and SecondaryNavigation deleted; route-sections contract 7 -> 12 routes plus a panel-swap suite | verify:pr-local (1 pre-existing root-permission failure in pr-handoff-stop.test.ts, all else green); test 5932 passed; in-page-nav-route-sections 29 passed; verify:phone-chrome 3/4 stages (focused-browser blocked by #255 Chromium 1194 vs 1234); build + bundle-budget + rag:fixtures green; verify:ui not run (#255, delegated to CI) | +| 2026-08-09 | PR #1782 / cursor/fix-document-open-scroll-e5bf | 5709f2cc7a954197e02107c96d7896d8d13445c3 | document-viewer open-at-top | ship: remove chunk mount scrollIntoView so document opens stay at overview top | document-viewer-shell.dom 7 pass; document-section-summary.dom 8 pass; verify:pr-local dry-run | +| 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 98029875db7d640d3e699829249bb33892296bff | PR #1782 unblock | before: static-pr+coverage failed on stale adoption-manifest (document-viewer-shell testFiles drift), merge-tree clean 0 behind, auto-merge armed, 1 advisory CodeRabbit waitFor thread; after: regenerated adoption-manifest, hardened scroll negative assertion, pre-commit+handoff adoption sync to prevent recurrence; CodeRabbit dispositioned as fixed by sync assert | check:design-system-adoption PASS; vitest design-system-adoption+document-viewer-shell+docs-inventory 63/63 PASS; format; no provider-backed checks | | 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 86698228533ebe10452c10c1bd7a3e1610d891ae | PR #1782 unblock | merged origin/main (behind-but-clean); fixed static-pr TS2322 on document-viewer-shell chunk fixture; fixed Production UI DSM compare remove stall via location.assign + DOM proof; prior adoption-manifest drift already fixed | tsc clean for changed files; vitest document-viewer-shell+dsm-compare-remove+design-system-adoption 59/59 PASS; check:design-system-adoption PASS; format; no provider-backed checks | diff --git a/src/components/therapy-compass/data/select.ts b/src/components/therapy-compass/data/select.ts index 49ae511ca3..cc60850dde 100644 --- a/src/components/therapy-compass/data/select.ts +++ b/src/components/therapy-compass/data/select.ts @@ -40,6 +40,74 @@ export function summarise(text: string | null, sentences = 1): string { return parts.slice(0, sentences).join(" ").trim(); } +/** + * True when a sentence is only a therapy-name restatement, or starts with that + * name before a word boundary (alias suffixes like `(CT)` / `, DT`, or prose + * such as "Behavioural activation is…"). Prefix-sharing words without a + * boundary ("Behavioural activationism") stay. + */ +function isExcludedTitleSentence(part: string, exclude: string): boolean { + if (!exclude) return false; + const normalized = part.toLowerCase().replace(/[.]+$/, "").trim(); + if (!normalized) return false; + if (normalized === exclude) return true; + const escaped = exclude.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}\\b`).test(normalized); +} + +/** + * Compact card copy: skip a leading sentence that merely restates the therapy + * name (common in clinicalSummary), then return up to `maxSentences` of the + * remainder. Empty when nothing useful remains. + */ +export function cardPreviewText( + text: string | null | undefined, + options: { exclude?: string | null; maxSentences?: number } = {}, +): string { + if (!text) return ""; + const maxSentences = options.maxSentences ?? 1; + const exclude = (options.exclude ?? "").trim().toLowerCase().replace(/[.]+$/, ""); + const parts = text + .split(/(?<=\.)\s+/) + .map((part) => part.trim()) + .filter(Boolean); + + const useful = parts.filter((part) => !isExcludedTitleSentence(part, exclude)); + + return useful.slice(0, maxSentences).join(" ").trim(); +} + +/** + * Surface filter/query-relevant tags first so a one-row TagRow shows what + * matched the search rather than the catalogue's domain prefix. + */ +export function prioritiseTherapyTags( + tags: string[], + options: { query?: string; activeTags?: string[] } = {}, +): string[] { + if (!tags.length) return tags; + const active = new Set((options.activeTags ?? []).map((tag) => tag.toLowerCase())); + const tokens = (options.query ?? "") + .toLowerCase() + .split(/[^a-z0-9/+-]+/) + .map((token) => token.trim()) + .filter((token) => token.length >= 2); + + const rank = (tag: string) => { + const lower = tag.toLowerCase(); + if (active.has(lower)) return 0; + if (tokens.some((token) => lower === token || lower.includes(token) || token.includes(lower))) { + return 1; + } + return 2; + }; + + return tags + .map((tag, index) => ({ tag, index, rank: rank(tag) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map((entry) => entry.tag); +} + export function reviewStatusMeta(status: string): { label: string; tone: "warning" | "success" | "neutral" } { if (status === "reviewed") return { label: "Reviewed", tone: "success" }; if (status === "needs_review") return { label: "Needs source review", tone: "warning" }; diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx index 5b97ef15a3..dcc1c11a16 100644 --- a/src/components/therapy-compass/screens/search-screen.tsx +++ b/src/components/therapy-compass/screens/search-screen.tsx @@ -47,7 +47,7 @@ export function SearchScreen() { const [filterOpen, setFilterOpen] = useState(false); return ( -
+
} filterControls={ -
+
{QUICK_TAGS.map((tag) => { const on = b.search.tags.includes(tag); return ( diff --git a/src/components/therapy-compass/therapy-card.tsx b/src/components/therapy-compass/therapy-card.tsx index 0b6eab6d1a..be01d2fd7c 100644 --- a/src/components/therapy-compass/therapy-card.tsx +++ b/src/components/therapy-compass/therapy-card.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { ignoreUnavailableActivation } from "@/components/ui-primitives"; import { useTcBindings } from "./bindings"; -import { summarise } from "./data/select"; +import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select"; import type { Therapy } from "./data/types"; import { accentControl, outlineControl, therapyBtn } from "./controls"; import { @@ -23,90 +23,101 @@ import { Eyebrow, IconTile, TagRow } from "./ui"; export function ResultCard({ therapy }: { therapy: Therapy }) { const b = useTcBindings(); const inCompare = b.isInCompare(therapy.slug); + const subtitle = + cardPreviewText(therapy.clinicalSummary, { exclude: therapy.name }) || + cardPreviewText(therapy.bestUsedFor, { exclude: therapy.name }) || + ""; + const tags = prioritiseTherapyTags(therapy.tags.length ? therapy.tags : [therapy.category], { + query: b.search.query, + activeTags: b.search.tags, + }); + const whyMatched = + cardPreviewText(therapy.bestUsedFor, { exclude: therapy.name }) || + cardPreviewText(therapy.indications, { exclude: therapy.name }) || + "Relevant to the current search."; + const avoidModify = + summarise(therapy.contraindicationsOrCautions, 1) || "Check source and review status before clinical use."; + const bestFit = + cardPreviewText(therapy.targetSymptoms, { exclude: therapy.name }) || + cardPreviewText(therapy.patientPopulation, { exclude: therapy.name }) || + cardPreviewText(therapy.setting, { exclude: therapy.name }) || + "See record for population fit."; + + const sheetLabel = therapy.patientSheetAvailable ? "Patient sheet" : "Sheet unavailable"; + const sheetShort = therapy.patientSheetAvailable ? "Sheet" : "No sheet"; + return ( -
-
-
- -
-

+
+ +
+
+ +
+

{therapy.name}

-

- {summarise(therapy.clinicalSummary, 1) || therapy.bestUsedFor || therapy.category} -

- + {subtitle ? ( +

+ {subtitle} +

+ ) : ( +
+ )} +
- - - -
- -
- + + +
-
+
@@ -126,13 +137,13 @@ function CardCell({ }) { return (
-
+
{eyebrow}
-

{text}

+

{text}

); } diff --git a/src/components/therapy-compass/ui.tsx b/src/components/therapy-compass/ui.tsx index 5ca3e1428f..c0546dcb4d 100644 --- a/src/components/therapy-compass/ui.tsx +++ b/src/components/therapy-compass/ui.tsx @@ -69,17 +69,40 @@ export function Tag({ children, tone = "neutral" }: { children: ReactNode; tone? ); } -export function TagRow({ tags, max = 5 }: { tags: string[]; max?: number }) { +export function TagRow({ + tags, + max = 5, + /** Search cards keep one row; detail may still wrap a longer set. */ + wrap = true, +}: { + tags: string[]; + max?: number; + wrap?: boolean; +}) { const shown = tags.slice(0, max); const extra = tags.length - shown.length; + const pills = shown.map((tag) => ( + + {tag} + + )); + const overflow = extra > 0 ? +{extra} : null; + + // Single-row cards: keep `+N` as a non-shrinking sibling so overflow-hidden + // clips long tags, not the indicator that more tags exist. + if (!wrap) { + return ( +
+
{pills}
+ {overflow ? {overflow} : null} +
+ ); + } + return (
- {shown.map((tag) => ( - - {tag} - - ))} - {extra > 0 ? +{extra} : null} + {pills} + {overflow}
); } diff --git a/tests/therapy-card-preview.test.ts b/tests/therapy-card-preview.test.ts new file mode 100644 index 0000000000..54a3b36f23 --- /dev/null +++ b/tests/therapy-card-preview.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { cardPreviewText, prioritiseTherapyTags } from "@/components/therapy-compass/data/select"; + +describe("cardPreviewText", () => { + it("skips a leading sentence that restates the therapy name", () => { + const name = "CBT-informed psychological intervention for self-harm"; + const summary = `${name}. A structured, person-centred psychological intervention for adults who self-harm.`; + + expect(cardPreviewText(summary, { exclude: name })).toBe( + "A structured, person-centred psychological intervention for adults who self-harm.", + ); + }); + + it("skips title restatements that include a parenthetical or comma alias", () => { + expect( + cardPreviewText("Cognitive Therapy (CT). Classic strongest uses are depression and anxiety disorders.", { + exclude: "Cognitive Therapy", + }), + ).toBe("Classic strongest uses are depression and anxiety disorders."); + + expect( + cardPreviewText("Dignity therapy, DT. Best in palliative care and advanced illness.", { + exclude: "Dignity therapy", + }), + ).toBe("Best in palliative care and advanced illness."); + }); + + it("skips a title-prefixed prose sentence while keeping unrelated prefix-sharing words", () => { + expect( + cardPreviewText("Behavioural activation is a structured approach. Prefer when avoidance dominates.", { + exclude: "Behavioural activation", + }), + ).toBe("Prefer when avoidance dominates."); + + expect( + cardPreviewText("Behavioural activationism remains distinct from the named therapy.", { + exclude: "Behavioural activation", + }), + ).toBe("Behavioural activationism remains distinct from the named therapy."); + }); + + it("returns empty when every sentence is the excluded title", () => { + const name = "Behavioural activation"; + expect(cardPreviewText(`${name}.`, { exclude: name })).toBe(""); + }); + + it("summarises to the requested sentence count", () => { + expect(cardPreviewText("First claim. Second claim. Third claim.", { maxSentences: 2 })).toBe( + "First claim. Second claim.", + ); + }); +}); + +describe("prioritiseTherapyTags", () => { + const tags = ["Mood", "Anxiety", "Trauma", "Psychosis", "Crisis/risk", "CBT", "Single session"]; + + it("surfaces active filter tags first, then query matches, then catalogue order", () => { + expect(prioritiseTherapyTags(tags, { query: "CBT", activeTags: ["Trauma"] })).toEqual([ + "Trauma", + "CBT", + "Mood", + "Anxiety", + "Psychosis", + "Crisis/risk", + "Single session", + ]); + }); + + it("keeps catalogue order when nothing is relevant", () => { + expect(prioritiseTherapyTags(tags, { query: "", activeTags: [] })).toEqual(tags); + }); +}); diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index 64b5c490f0..30fdb5607b 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -63,6 +63,8 @@ describe("Therapy Compass production-mode wiring", () => { // Search route owns filters/results only; the results ribbon is the page h1. expect(searchSrc).toContain("SearchResultsHeaderBand"); expect(searchSrc).toContain("headingLevel={1}"); + // Documents-style gap so the band does not sit flush on the first card. + expect(searchSrc).toContain("space-y-2.5 sm:space-y-3"); expect(searchSrc).not.toContain("Search therapies"); expect(searchSrc).not.toContain("Find source-grounded therapy records"); expect(workspaceSrc).toContain("Therapy could not load"); diff --git a/tests/therapy-compass-responsive-contract.test.ts b/tests/therapy-compass-responsive-contract.test.ts index b124b2c0db..520119c069 100644 --- a/tests/therapy-compass-responsive-contract.test.ts +++ b/tests/therapy-compass-responsive-contract.test.ts @@ -163,6 +163,34 @@ describe("Therapy Compass responsive contract", () => { expect(favouriteButton).toContain("cursor-not-allowed"); }); + it("keeps search result cards dense: single-row tags, top favourite, clamped match cells", () => { + expect(therapyCardSource).toContain("wrap={false}"); + expect(therapyCardSource).toContain("max={3}"); + expect(therapyCardSource).toContain("prioritiseTherapyTags"); + expect(therapyCardSource).toContain("cardPreviewText"); + expect(therapyCardSource).toContain("line-clamp-2"); + expect(therapyCardSource).toContain("grid-cols-3"); + // Favourite is pinned to the card corner; no heart-only desktop column. + expect(therapyCardSource).toContain("absolute top-3 right-3"); + // Two-column card body waits for `md` so 640–700px viewports do not overflow. + expect(therapyCardSource).toContain("md:grid-cols-[minmax(240px,1fr)_minmax(320px,1.35fr)]"); + expect(therapyCardSource).not.toMatch(/sm:grid-cols-\[minmax\([^)]+\),1fr\)_minmax\([^)]+\),1\.35fr\)/); + expect(therapyCardSource).not.toMatch(/sm:grid-cols-\[minmax\([^)]+\),1fr\)_minmax\([^)]+\),1\.35fr\)_auto\]/); + // Fallbacks run after preview filtering so a title-only first field can yield. + expect(therapyCardSource).toMatch( + /cardPreviewText\(therapy\.bestUsedFor,\s*\{\s*exclude:\s*therapy\.name\s*\}\)\s*\|\|/, + ); + expect(therapyCardSource).toMatch(/cardPreviewText\(therapy\.indications,\s*\{\s*exclude:\s*therapy\.name\s*\}\)/); + }); + + it("keeps the single-row TagRow overflow indicator unclipped", () => { + const uiSource = read(`${therapyPath}/ui.tsx`); + expect(uiSource).toContain("flex-nowrap gap-2 overflow-hidden"); + expect(uiSource).toContain("+{extra}"); + // `+N` is a shrink-0 sibling outside the clipping flex row. + expect(uiSource).toMatch(/overflow-hidden[\s\S]*?\{pills\}[\s\S]*?shrink-0[\s\S]*?\{overflow\}/); + }); + it("uses complete toggle semantics and preserves full-size control hit targets", () => { const briefGroupTag = openingTagWith(briefSource, "div", [ 'role="group"',