From 2fac4ac26330280d61fa39240161aa53fbf66440 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 13:37:27 +0000 Subject: [PATCH 01/11] Rebuild DSM documentation support as a criteria-anchored note builder The DSM diagnosis page rendered `documentation_template`, one fixed paragraph per record that asserts every criterion as met and enumerates every symptom the disorder can present with. For panic disorder that is all thirteen. Copying it documented findings that may never have been elicited, and it could not be edited, tailored or even copied from the page. It is replaced by a builder that records each criterion as met, not met or not assessed and emits only what was marked. A criterion left alone is written out as not assessed rather than quietly dropped, so the note cannot claim more than the assessment supports. Differentials are ticked only when actively excluded. Output is sanitised to characters a record system can display. The export uses 216 instances of a greater-than-or-equal sign plus arrows, dashes and curly quotes, and a threshold silently losing its sign reverses the meaning. Semicolons are folded to commas, and a criterion line is sentence-cased because spelling out a leading threshold otherwise opens the line mid-word. The builder is handed a projection rather than the whole record. It is a Client Component, so its props cross into the RSC payload, and passing the diagnosis shipped `documentation_template` to the browser on all 146 pages. `dsm.ts` records the same trap for the category export. Also on this page: - A specifier row that states the disorder HAS no specifiers was being counted as one. Ten records reported "1 specifier" when the true answer is none. `dsmSpecifierSplit` separates the two, keeping the six such rows that carry a real description as prose. - The Record summary card repeated criteria, specifier and severity counts from the at-a-glance strip and the ICD-10 code from the header chip. It now carries provenance, which was stranded in unlabelled footer pills. - The differentials sidebar silently truncated at six. Nine records carry more, and the count is now shown. - The severity tile said "Not listed", which reads as a gap in the catalogue rather than a fact about DSM-5-TR. - Added the cross-mode rail, which resolves a related page for 137 of the 146 records. The page was otherwise a dead end. Specifier menu names ("Mild / Moderate / Severe") are deliberately not parsed into options. Every splitting rule tried against the corpus produced wrong text for at least six of the seventeen, and wrong text here is pasted into a record. Those rows stay visible as reference and are typed into the free-text field. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- docs/design-system/COMPONENTS.md | 6 +- docs/design-system/adoption-manifest.json | 6 + .../dsm/dsm-diagnosis-note-builder.tsx | 243 ++++++++++++++++++ src/components/dsm/dsm-diagnosis-page.tsx | 117 +++++---- src/lib/dsm-note.ts | 181 +++++++++++++ src/lib/dsm.ts | 37 ++- tests/dsm-diagnosis-note-builder.dom.test.tsx | 87 +++++++ tests/dsm-note.test.ts | 191 ++++++++++++++ tests/in-page-nav-route-sections.dom.test.tsx | 7 + 9 files changed, 822 insertions(+), 53 deletions(-) create mode 100644 src/components/dsm/dsm-diagnosis-note-builder.tsx create mode 100644 src/lib/dsm-note.ts create mode 100644 tests/dsm-diagnosis-note-builder.dom.test.tsx create mode 100644 tests/dsm-note.test.ts diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index e73677470..f42dd1bbc 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1043,8 +1043,8 @@ This generated snapshot is a local source-derived inventory. It does not assert | `AnswerFooter` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `Button` | controls | yes | yes | inherited-global-root | yes | no | 26 | -| `Checkbox` | controls | yes | yes | inherited-global-root | yes | no | 2 | +| `Button` | controls | yes | yes | inherited-global-root | yes | no | 27 | +| `Checkbox` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Chip` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `ChoiceChip` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Citation` | source | yes | yes | no | yes | no | 0 | @@ -1077,7 +1077,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 2 | | `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 | | `SearchField` | controls | yes | yes | no | yes | no | 0 | -| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 10 | +| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 11 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 34 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 652311174..bd53ce3f6 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -290,6 +290,7 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/ClinicalSidebar.tsx", "src/components/clinical-dashboard/signed-image.tsx", + "src/components/dsm/dsm-diagnosis-note-builder.tsx", "src/components/on-call/on-call-contacts-section.tsx", "src/components/on-call/on-call-entry-editor.tsx", "src/components/on-call/on-call-section-page.tsx", @@ -318,6 +319,7 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/ClinicalSidebar.tsx", "src/components/clinical-dashboard/signed-image.tsx", + "src/components/dsm/dsm-diagnosis-note-builder.tsx", "src/components/on-call/on-call-contacts-section.tsx", "src/components/on-call/on-call-entry-editor.tsx", "src/components/on-call/on-call-section-page.tsx", @@ -379,10 +381,12 @@ "entryExported": true, "directImportFiles": [ "src/components/clinical-dashboard/document-admin.tsx", + "src/components/dsm/dsm-diagnosis-note-builder.tsx", "src/components/on-call/on-call-entry-editor.tsx" ], "productImportFiles": [ "src/components/clinical-dashboard/document-admin.tsx", + "src/components/dsm/dsm-diagnosis-note-builder.tsx", "src/components/on-call/on-call-entry-editor.tsx" ], "designSync": { @@ -1664,6 +1668,7 @@ "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/dictionary/dictionary-catalogue-pages.tsx", + "src/components/dsm/dsm-diagnosis-note-builder.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", @@ -1676,6 +1681,7 @@ "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", "src/components/dictionary/dictionary-catalogue-pages.tsx", + "src/components/dsm/dsm-diagnosis-note-builder.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", diff --git a/src/components/dsm/dsm-diagnosis-note-builder.tsx b/src/components/dsm/dsm-diagnosis-note-builder.tsx new file mode 100644 index 000000000..9508bb142 --- /dev/null +++ b/src/components/dsm/dsm-diagnosis-note-builder.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { MessageSquareText, RotateCcw } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; + +import { inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/choice"; +import { CopyButton } from "@/components/ui/copy-button"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { cn, fieldControlPlain, fieldLabel } from "@/components/ui-primitives"; +import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; +import { buildDsmDiagnosisNote, type DsmCriterionStatus, type DsmNoteBuilderRecord } from "@/lib/dsm-note"; + +/** + * Replaces the `documentation_template` paragraph this section used to render. + * + * That paragraph was a single fixed string per record which asserted every + * criterion as met and enumerated every symptom the disorder can present with — + * for panic disorder, all thirteen. Pasting it documented findings that may + * never have been elicited, and it could not be edited, tailored or even copied + * from the page. Everything this builder emits comes from what the clinician + * ticked, and a criterion left alone is reported as not assessed rather than + * quietly dropped, so the note cannot claim more than the assessment supports. + */ + +const STATUS_OPTIONS: ReadonlyArray<{ value: DsmCriterionStatus; label: string }> = [ + { value: "met", label: "Met" }, + { value: "not-met", label: "Not met" }, + { value: "not-assessed", label: "Not assessed" }, +]; + +const COPY_RESET_MS = 2200; + +export function DsmDiagnosisNoteBuilder({ record }: { record: DsmNoteBuilderRecord }) { + const { criteria, differentials, specifiers: selectableSpecifiers } = record; + + const [statuses, setStatuses] = useState>({}); + const [specifiers, setSpecifiers] = useState([]); + const [specifierText, setSpecifierText] = useState(""); + const [excluded, setExcluded] = useState([]); + const [includeCriterionText, setIncludeCriterionText] = useState(true); + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); + const copyTimer = useRef(null); + + const criterionKey = (label: string, index: number) => label || String(index + 1); + + const note = useMemo( + () => + buildDsmDiagnosisNote({ + title: record.title, + icdCode: record.icdCode, + criteria: criteria.map((criterion, index) => ({ + label: criterionKey(criterion.label, index), + text: criterion.text, + status: statuses[criterionKey(criterion.label, index)] ?? "not-assessed", + })), + specifiers, + specifierText, + excludedDifferentials: excluded, + includeCriterionText, + }), + [criteria, record, statuses, specifiers, specifierText, excluded, includeCriterionText], + ); + + function toggle(list: string[], value: string) { + return list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]; + } + + function resetBuilder() { + setStatuses({}); + setSpecifiers([]); + setSpecifierText(""); + setExcluded([]); + setIncludeCriterionText(true); + } + + async function copyNote() { + try { + await copyTextToClipboard(note); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + if (copyTimer.current) window.clearTimeout(copyTimer.current); + copyTimer.current = window.setTimeout(() => setCopyState("idle"), COPY_RESET_MS); + } + + const assessedCount = criteria.filter( + (criterion, index) => (statuses[criterionKey(criterion.label, index)] ?? "not-assessed") !== "not-assessed", + ).length; + + return ( +
+
+ +

+ Documentation support +

+ + {assessedCount} of {criteria.length} recorded + +
+

+ Record each criterion against your assessment. The note is built from what you mark, and anything you leave + alone is written out as not assessed. +

+ +
+ Criteria assessment + {criteria.map((criterion, index) => { + const key = criterionKey(criterion.label, index); + const status = statuses[key] ?? "not-assessed"; + return ( +
+

+ {key}. + {criterion.text} +

+ setStatuses((current) => ({ ...current, [key]: next }))} + options={STATUS_OPTIONS} + layout="equal" + /> +
+ ); + })} +
+ +
+
+

Specifiers

+ {selectableSpecifiers.length > 0 ? ( +
+ {selectableSpecifiers.map((specifier) => ( + setSpecifiers((current) => toggle(current, specifier.name))} + /> + ))} +
+ ) : ( +

+ No single-value specifiers in this record. Type any that apply below. +

+ )} + + setSpecifierText(event.target.value)} + placeholder="moderate, recurrent" + className={fieldControlPlain} + /> +
+ +
+

Differentials excluded

+

+ Tick only those you actively considered and ruled out. +

+
+ {differentials.map((differential) => ( + setExcluded((current) => toggle(current, differential))} + /> + ))} +
+
+
+ +
+
+

Note

+
+ setIncludeCriterionText(event.target.checked)} + /> +
+
+ {note ? ( + <> +
+              {note}
+            
+
+ void copyNote()} + testId="dsm-note-copy" + /> + +
+

+ {copyState === "copied" + ? "Note copied to the clipboard." + : copyState === "failed" + ? "Copying failed. Select the note text and copy it manually." + : ""} +

+ + ) : ( +

+ Mark at least one criterion to build the note. +

+ )} +

+ Plain characters only, so the text pastes cleanly into a record. This is a documentation aid and not a + substitute for diagnostic reasoning or local documentation requirements. +

+
+
+ ); +} diff --git a/src/components/dsm/dsm-diagnosis-page.tsx b/src/components/dsm/dsm-diagnosis-page.tsx index d84e2baca..b114ac718 100644 --- a/src/components/dsm/dsm-diagnosis-page.tsx +++ b/src/components/dsm/dsm-diagnosis-page.tsx @@ -8,18 +8,26 @@ import { GitCompareArrows, Gauge, ListChecks, - MessageSquareText, ShieldCheck, Signpost, SlidersHorizontal, } from "lucide-react"; +import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { DsmDiagnosisNavHeader } from "@/components/dsm/dsm-diagnosis-nav-header"; +import { DsmDiagnosisNoteBuilder } from "@/components/dsm/dsm-diagnosis-note-builder"; import { DsmPageHeader } from "@/components/dsm/dsm-page-header"; import { InformationPageShell } from "@/components/information-page-shell"; import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; -import { dsmCriteria, resolveDsmDifferential, type DsmDiagnosis, type DsmLabeledText } from "@/lib/dsm"; +import { + dsmCriteria, + dsmSpecifierSplit, + resolveDsmDifferential, + type DsmDiagnosis, + type DsmLabeledText, +} from "@/lib/dsm"; +import { dsmNoteBuilderRecord } from "@/lib/dsm-note"; /** * Explicit singular and plural rather than appending "s", because the one that @@ -76,8 +84,15 @@ function CriteriaRow({ criterion, index }: { criterion: DsmLabeledText; index: n export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) { const criteria = dsmCriteria(diagnosis); + const { specifiers, absentNotes } = dsmSpecifierSplit(diagnosis); const compareHref = `/dsm/compare?ids=${encodeURIComponent(diagnosis.slug)}`; + // The sidebar shows the first six and links out for the rest. Nine records + // carry more than six, and before this the extra ones simply vanished with no + // sign that the list had been cut. + const sidebarDifferentials = diagnosis.differentials.slice(0, 6); + const hiddenDifferentialCount = diagnosis.differentials.length - sidebarDifferentials.length; + // "4 criteria, A-D" / "1 criterion, A". Twelve records carry a single criterion, // so the range is appended only when there are at least two to span — otherwise // it would read "A-A". Labels fall back to the ordinal the list rows already use. @@ -142,7 +157,7 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) { @@ -235,13 +250,11 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) {

Specifiers

- - {diagnosis.specifiers.length} - + {specifiers.length} - {diagnosis.specifiers.length ? ( + {specifiers.length ? (
- {diagnosis.specifiers.map((specifier) => ( + {specifiers.map((specifier) => (
) : ( -

- No specifiers were included in the supplied record. -

+
+ {absentNotes.length ? ( + absentNotes.map((note) => ( +

+ {note.name}. + {note.description ? ` ${note.description}` : ""} +

+ )) + ) : ( +

No specifiers were included in the supplied record.

+ )} +
)} -
-
- -

- Documentation support -

-
-

- {diagnosis.documentation_template} -

-

- Adapt this supplied template to the assessment. It is not a substitute for diagnostic reasoning or - local documentation requirements. -

-
+ + + {/* Renders nothing when no other mode holds a match for this + diagnosis, so a record with no medication, service or form + match keeps the page exactly as it was. */} +
+ {/* The record id and catalogue moved into the Record summary card, + which labels them. Repeating them here left the page ending on two + unlabelled pills that said the same thing. */}
DSM-5 Diagnosis - {diagnosis.record_id} - Supplied local catalogue
diff --git a/src/lib/dsm-note.ts b/src/lib/dsm-note.ts new file mode 100644 index 000000000..a0ddb63de --- /dev/null +++ b/src/lib/dsm-note.ts @@ -0,0 +1,181 @@ +import { dsmCriteria, dsmSpecifierSplit, type DsmDiagnosis, type DsmLabeledText, type DsmSpecifier } from "@/lib/dsm"; + +/** + * Note text generation for the DSM diagnosis page's note builder. + * + * This module is deliberately pure and free of React so the wording — the part + * that ends up pasted into a progress note — is unit-testable without rendering. + * + * Why it exists: the page previously rendered `documentation_template`, one + * fixed paragraph per record that asserts EVERY criterion as met and enumerates + * every symptom the disorder can present with. Copying it documents findings + * that may never have been elicited. Everything here is built from what the + * clinician actually ticked, and an unassessed criterion is stated as such + * rather than silently omitted. + */ + +export type DsmCriterionStatus = "met" | "not-met" | "not-assessed"; + +export type DsmNoteCriterion = { + label: string; + text: string; + status: DsmCriterionStatus; +}; + +export type DsmNoteInput = { + title: string; + icdCode: string; + criteria: DsmNoteCriterion[]; + /** Ticked specifier names, verbatim from the record. */ + specifiers: string[]; + /** Free-text specifiers the clinician typed, comma separated. */ + specifierText: string; + /** Differentials the clinician ticked as considered and excluded. */ + excludedDifferentials: string[]; + /** When false the criteria blocks list labels only, for a shorter note. */ + includeCriterionText: boolean; +}; + +/** + * Characters that survive a copy into a clinical record system. + * + * The vendored DSM export uses typographic characters throughout — 216 uses of + * `≥` alone, plus `≤ ≈ × ² – — → ↑` and curly quotes. Several of the systems + * this text is pasted into render those as replacement glyphs or drop them, and + * `≥4` silently becoming `4` reverses the meaning of a threshold. Each one is + * therefore spelled out rather than stripped. + * + * `â` (khyâl) and `é` (Guillain-Barré) are left alone: both are ordinary Latin-1 + * letters inside a correctly spelled clinical term, not typography. + * + * Semicolons are folded to commas because every semicolon in this corpus joins + * list items or an "or" clause, where a comma reads identically and matches how + * notes are written. + */ +const PLAIN_TEXT_REPLACEMENTS: ReadonlyArray = [ + [/≥\s*/g, "at least "], + [/≤\s*/g, "no more than "], + [/≈\s*/g, "approximately "], + [/↑\s*/g, "increased "], + [/\s*→\s*/g, " leading to "], + [/×/g, "x"], + [/²/g, "2"], + [/[–—]/g, "-"], + [/[‘’]/g, "'"], + [/[“”]/g, '"'], + [/;\s*/g, ", "], +]; + +export function plainClinicalText(value: string): string { + let text = value; + for (const [pattern, replacement] of PLAIN_TEXT_REPLACEMENTS) text = text.replace(pattern, replacement); + return text.replace(/[ \t]{2,}/g, " ").trim(); +} + +/** + * The specifier rows that are safe to offer as a tick box. + * + * 17 of the 315 specifier names in the export are slash menus rather than single + * values, and they do not share one shape: "Mild / Moderate / Severe" is three + * values, "Severity (Mild / Moderate / Severe)" wraps them in a parenthesis, + * "Cannabis use disorder - Mild / Moderate / Severe" carries a prefix, "Mild / + * Major neurocognitive disorder" carries a shared SUFFIX, and "With absent + * insight / delusional beliefs" is a single DSM specifier that happens to + * contain a slash. Every splitting rule tried against the real corpus produced + * wrong text for at least six of them ("Severity (Mild", "Cannabis use disorder + * - Mild", a bare "Mild"), and wrong text here is pasted into a record. + * + * So no menu is parsed. Menu rows stay visible in the specifiers panel as + * reference and the clinician types the value into the free-text field, which is + * the only option that cannot state something the record does not say. + */ +export function dsmSelectableSpecifiers(specifiers: DsmSpecifier[]): DsmSpecifier[] { + return specifiers.filter((specifier) => !specifier.name.includes(" / ")); +} + +/** + * A criterion line opens a sentence, so it has to read like one. + * + * 216 criteria in the export begin with a threshold sign, and spelling that out + * leaves the line starting mid-word: "B. at least 1 attack followed by...". + * Only the first character is touched, so an accented or bracketed opening is + * left exactly as the record has it. + */ +function sentenceCase(value: string) { + return value.charAt(0).toLocaleUpperCase("en-AU") + value.slice(1); +} + +function criteriaWithStatus(criteria: DsmNoteCriterion[], status: DsmCriterionStatus) { + return criteria.filter((criterion) => criterion.status === status); +} + +function criteriaBlock(heading: string, criteria: DsmNoteCriterion[], includeText: boolean) { + const labels = criteria.map((criterion) => criterion.label).join(", "); + if (!includeText) return `${heading} (${labels}).`; + const lines = criteria.map((criterion) => `${criterion.label}. ${sentenceCase(plainClinicalText(criterion.text))}`); + return [`${heading} (${labels}):`, ...lines].join("\n"); +} + +/** + * The pasteable note. Returns an empty string when nothing has been recorded, + * so the surface can prompt rather than offer an empty skeleton to copy. + */ +export function buildDsmDiagnosisNote(input: DsmNoteInput): string { + const met = criteriaWithStatus(input.criteria, "met"); + const notMet = criteriaWithStatus(input.criteria, "not-met"); + const notAssessed = criteriaWithStatus(input.criteria, "not-assessed"); + const specifiers = [...input.specifiers, ...input.specifierText.split(",")] + .map((value) => plainClinicalText(value)) + .filter(Boolean); + const excluded = input.excludedDifferentials.map(plainClinicalText).filter(Boolean); + + if (met.length === 0 && notMet.length === 0 && specifiers.length === 0 && excluded.length === 0) return ""; + + const headingParts = [`${plainClinicalText(input.title)} (${input.icdCode})`, ...specifiers]; + const blocks: string[] = [headingParts.join(", ")]; + + if (met.length > 0) blocks.push(criteriaBlock("Criteria met", met, input.includeCriterionText)); + if (notMet.length > 0) blocks.push(criteriaBlock("Criteria not met", notMet, input.includeCriterionText)); + // Always full text: the point of this block is that the reader can see exactly + // what remains open, which a bare letter does not convey. + if (notAssessed.length > 0) blocks.push(criteriaBlock("Not assessed", notAssessed, true)); + if (excluded.length > 0) blocks.push(`Differentials considered and excluded: ${excluded.join(", ")}.`); + + blocks.push("Recorded against DSM-5-TR criteria. Confirm against the full assessment."); + return blocks.join("\n\n"); +} + +/** + * Exactly what the note builder needs, and nothing else. + * + * The builder is a Client Component, so every field of whatever it is handed is + * serialised into the RSC payload and shipped to the browser. Passing the whole + * `DsmDiagnosis` sent `documentation_template` with it — 538 to 1323 characters + * per record of the very prose this change exists to stop people pasting, dead + * weight in the payload of all 146 diagnosis pages. `src/lib/dsm.ts` records the + * same trap for the category export, where spreading the raw row would have + * shipped `css_class` and a raw hex to the browser. + * + * Projecting on the server keeps the wire shape honest to what the UI reads. + */ +export type DsmNoteBuilderRecord = { + title: string; + icdCode: string; + criteria: DsmLabeledText[]; + /** Already filtered to the rows that are safe to offer as a tick box. */ + specifiers: DsmSpecifier[]; + differentials: string[]; +}; + +export function dsmNoteBuilderRecord(diagnosis: DsmDiagnosis): DsmNoteBuilderRecord { + return { + title: diagnosis.title, + icdCode: diagnosis.icd_code, + criteria: dsmCriteria(diagnosis).map(({ label, text }) => ({ label, text })), + specifiers: dsmSelectableSpecifiers(dsmSpecifierSplit(diagnosis).specifiers).map(({ name, description }) => ({ + name, + description, + })), + differentials: diagnosis.differentials, + }; +} diff --git a/src/lib/dsm.ts b/src/lib/dsm.ts index bb62f86ea..4cd3b2f5d 100644 --- a/src/lib/dsm.ts +++ b/src/lib/dsm.ts @@ -223,6 +223,41 @@ export function dsmCriteria(diagnosis: DsmDiagnosis) { return diagnosis.criteria_display.length > 0 ? diagnosis.criteria_display : diagnosis.key_features; } +/** + * A specifier row that is really a statement that the disorder HAS no + * specifiers, e.g. "No DSM-5-TR specifiers for this disorder". + * + * Ten records carry one of these, and on all ten it is the ONLY row in + * `specifiers` — the upstream export uses the array as a slot for the sentence + * rather than leaving it empty. Counting it made every one of those records + * report "1 specifier" in the at-a-glance strip and the record summary when the + * true answer is none, which is a factual error about the diagnostic standard + * and not a rendering nicety. + * + * The rows are still worth rendering: six of the ten carry a real description + * (ARFID's sensory/fear-of-consequences/low-interest subtypes, pica's context + * examples), so `dsmSpecifierSplit` separates them rather than dropping them. + */ +function isDsmAbsentSpecifierNote(specifier: DsmSpecifier) { + return /^no\b/i.test(specifier.name.trim()) && /specifier/i.test(specifier.name); +} + +export type DsmSpecifierSplit = { + /** Rows that name an actual specifier. This length is the count to display. */ + specifiers: DsmSpecifier[]; + /** Rows stating the disorder has none. Rendered as prose, never counted. */ + absentNotes: DsmSpecifier[]; +}; + +export function dsmSpecifierSplit(diagnosis: DsmDiagnosis): DsmSpecifierSplit { + const specifiers: DsmSpecifier[] = []; + const absentNotes: DsmSpecifier[] = []; + for (const specifier of diagnosis.specifiers) { + (isDsmAbsentSpecifierNote(specifier) ? absentNotes : specifiers).push(specifier); + } + return { specifiers, absentNotes }; +} + export function dsmDiagnosisSummary(diagnosis: DsmDiagnosis): DsmDiagnosisSummary { const criteria = dsmCriteria(diagnosis); return { @@ -233,7 +268,7 @@ export function dsmDiagnosisSummary(diagnosis: DsmDiagnosis): DsmDiagnosisSummar summary: criteria[0]?.text ?? diagnosis.key_features[0]?.text ?? "Review the complete diagnostic record.", criteriaCount: criteria.length, differentialCount: diagnosis.differentials.length, - specifierCount: diagnosis.specifiers.length, + specifierCount: dsmSpecifierSplit(diagnosis).specifiers.length, }; } diff --git a/tests/dsm-diagnosis-note-builder.dom.test.tsx b/tests/dsm-diagnosis-note-builder.dom.test.tsx new file mode 100644 index 000000000..5adbafae5 --- /dev/null +++ b/tests/dsm-diagnosis-note-builder.dom.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { DsmDiagnosisNoteBuilder } from "@/components/dsm/dsm-diagnosis-note-builder"; +import { dsmDiagnoses } from "@/lib/dsm"; +import { dsmNoteBuilderRecord } from "@/lib/dsm-note"; + +afterEach(cleanup); + +const panic = dsmNoteBuilderRecord(dsmDiagnoses.find((diagnosis) => diagnosis.slug === "panic-disorder")!); + +function markCriterion(label: string, choice: string) { + const group = screen.getByRole("radiogroup", { name: `Criterion ${label}` }); + return within(group).getByRole("radio", { name: choice }); +} + +describe("DsmDiagnosisNoteBuilder", () => { + it("offers nothing to copy until a criterion is recorded", () => { + render(); + expect(screen.getByText("Mark at least one criterion to build the note.")).toBeTruthy(); + expect(screen.queryByTestId("dsm-note-output")).toBeNull(); + }); + + it("builds a note from the criteria marked and names the rest as not assessed", async () => { + const user = userEvent.setup(); + render(); + + await user.click(markCriterion("A", "Met")); + await user.click(markCriterion("B", "Met")); + + const note = screen.getByTestId("dsm-note-output").textContent ?? ""; + expect(note).toContain("Panic disorder (F41.0)"); + expect(note).toContain("Criteria met (A, B):"); + expect(note).toContain("Not assessed (C, D):"); + // The wall-of-prose template asserted every symptom; the built note must not. + expect(note).not.toContain("palpitations"); + }); + + it("writes threshold characters in a form a record system can display", async () => { + const user = userEvent.setup(); + render(); + await user.click(markCriterion("A", "Met")); + + const note = screen.getByTestId("dsm-note-output").textContent ?? ""; + expect(note).toContain("at least 4 of 13 symptoms"); + expect(note).not.toContain("≥"); + expect(note).not.toContain(";"); + }); + + it("records a criterion the clinician actively ruled out", async () => { + const user = userEvent.setup(); + render(); + await user.click(markCriterion("A", "Met")); + await user.click(markCriterion("C", "Not met")); + + const note = screen.getByTestId("dsm-note-output").textContent ?? ""; + expect(note).toContain("Criteria not met (C):"); + }); + + it("adds only the differentials that were ticked", async () => { + const user = userEvent.setup(); + render(); + await user.click(markCriterion("A", "Met")); + await user.click(screen.getByRole("checkbox", { name: /^GAD/ })); + + const note = screen.getByTestId("dsm-note-output").textContent ?? ""; + expect(note).toContain("Differentials considered and excluded: GAD"); + expect(note).not.toContain("Agoraphobia"); + }); + + it("offers no specifier tick box for a record that has none", () => { + render(); + expect(screen.getByText("No single-value specifiers in this record. Type any that apply below.")).toBeTruthy(); + }); + + it("clears every recorded answer on start over", async () => { + const user = userEvent.setup(); + render(); + await user.click(markCriterion("A", "Met")); + expect(screen.getByTestId("dsm-note-output")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: "Start over" })); + expect(screen.queryByTestId("dsm-note-output")).toBeNull(); + expect(markCriterion("A", "Met").getAttribute("aria-checked")).toBe("false"); + }); +}); diff --git a/tests/dsm-note.test.ts b/tests/dsm-note.test.ts new file mode 100644 index 000000000..c5edf5b30 --- /dev/null +++ b/tests/dsm-note.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { dsmDiagnoses, dsmSpecifierSplit } from "@/lib/dsm"; +import { + buildDsmDiagnosisNote, + dsmNoteBuilderRecord, + dsmSelectableSpecifiers, + plainClinicalText, + type DsmNoteCriterion, + type DsmNoteInput, +} from "@/lib/dsm-note"; + +const panic = dsmDiagnoses.find((diagnosis) => diagnosis.slug === "panic-disorder")!; + +function noteInput(criteria: DsmNoteCriterion[], overrides: Partial = {}): DsmNoteInput { + return { + title: "Panic disorder", + icdCode: "F41.0", + criteria, + specifiers: [], + specifierText: "", + excludedDifferentials: [], + includeCriterionText: true, + ...overrides, + }; +} + +describe("plainClinicalText", () => { + it("spells out threshold characters rather than dropping them", () => { + expect(plainClinicalText("≥4 of 13 symptoms")).toBe("at least 4 of 13 symptoms"); + expect(plainClinicalText("BMI ≤17 kg/m²")).toBe("BMI no more than 17 kg/m2"); + expect(plainClinicalText("within ≈1 minute")).toBe("within approximately 1 minute"); + expect(plainClinicalText("≥3×/week")).toBe("at least 3x/week"); + }); + + it("replaces arrows, dashes and curly quotes", () => { + expect(plainClinicalText("Restriction of energy intake → low body weight")).toBe( + "Restriction of energy intake leading to low body weight", + ); + expect(plainClinicalText("mood + ↑energy")).toBe("mood + increased energy"); + expect(plainClinicalText("2–3 days")).toBe("2-3 days"); + expect(plainClinicalText("the “index” episode")).toBe('the "index" episode'); + }); + + it("folds semicolons to commas", () => { + expect(plainClinicalText("palpitations; sweating; trembling")).toBe("palpitations, sweating, trembling"); + }); + + it("leaves accented clinical terms intact", () => { + expect(plainClinicalText("khyâl-related anxiety")).toBe("khyâl-related anxiety"); + expect(plainClinicalText("Guillain-Barré")).toBe("Guillain-Barré"); + }); + + it("emits no character outside Latin-1 across the whole catalogue", () => { + const allowed = /^[\x20-\x7E -ÿ\n]*$/; + for (const diagnosis of dsmDiagnoses) { + for (const criterion of diagnosis.key_features) { + expect(allowed.test(plainClinicalText(criterion.text))).toBe(true); + } + } + }); +}); + +describe("buildDsmDiagnosisNote", () => { + it("returns nothing until something has been recorded", () => { + expect(buildDsmDiagnosisNote(noteInput([{ label: "A", text: "x", status: "not-assessed" }]))).toBe(""); + }); + + it("never asserts a criterion the clinician did not mark met", () => { + const note = buildDsmDiagnosisNote( + noteInput([ + { label: "A", text: "Recurrent unexpected panic attacks", status: "met" }, + { label: "B", text: "Persistent concern about further attacks", status: "not-assessed" }, + { label: "C", text: "Not attributable to substances", status: "not-met" }, + ]), + ); + expect(note).toContain("Criteria met (A):"); + expect(note).toContain("Criteria not met (C):"); + expect(note).toContain("Not assessed (B):"); + // The unassessed criterion is named, not quietly dropped. + expect(note).toContain("B. Persistent concern about further attacks"); + }); + + it("opens each criterion line as a sentence", () => { + // "≥1 attack ..." sanitises to "at least 1 attack ...", which must not be + // pasted into a note starting mid-word. + const note = buildDsmDiagnosisNote( + noteInput([{ label: "B", text: "≥1 attack followed by ≥1 month of concern", status: "met" }]), + ); + expect(note).toContain("B. At least 1 attack followed by at least 1 month of concern"); + }); + + it("spells out an unassessed criterion even in label-only mode", () => { + const note = buildDsmDiagnosisNote( + noteInput( + [ + { label: "A", text: "Recurrent unexpected panic attacks", status: "met" }, + { label: "D", text: "Not better explained by another mental disorder", status: "not-assessed" }, + ], + { includeCriterionText: false }, + ), + ); + expect(note).toContain("Criteria met (A)."); + expect(note).not.toContain("A. Recurrent"); + expect(note).toContain("D. Not better explained by another mental disorder"); + }); + + it("carries ticked and typed specifiers onto the diagnosis line", () => { + const note = buildDsmDiagnosisNote( + noteInput([{ label: "A", text: "Recurrent unexpected panic attacks", status: "met" }], { + specifiers: ["With panic attacks"], + specifierText: "moderate, recurrent", + }), + ); + expect(note.split("\n")[0]).toBe("Panic disorder (F41.0), With panic attacks, moderate, recurrent"); + }); + + it("lists excluded differentials and always closes with the caveat", () => { + const note = buildDsmDiagnosisNote( + noteInput([{ label: "A", text: "Recurrent unexpected panic attacks", status: "met" }], { + excludedDifferentials: ["Agoraphobia", "GAD"], + }), + ); + expect(note).toContain("Differentials considered and excluded: Agoraphobia, GAD."); + expect(note.trimEnd().endsWith("Confirm against the full assessment.")).toBe(true); + }); +}); + +describe("specifier handling", () => { + it("does not count a 'no specifiers' row as a specifier", () => { + const split = dsmSpecifierSplit(panic); + expect(panic.specifiers).toHaveLength(1); + expect(split.specifiers).toHaveLength(0); + expect(split.absentNotes[0].name).toBe("No DSM-5-TR specifiers for this disorder"); + }); + + it("treats every 'no specifiers' record as having none, on all ten", () => { + const affected = dsmDiagnoses.filter((diagnosis) => dsmSpecifierSplit(diagnosis).absentNotes.length > 0); + expect(affected).toHaveLength(10); + for (const diagnosis of affected) { + expect(dsmSpecifierSplit(diagnosis).specifiers).toHaveLength(0); + } + }); + + it("offers no slash menu as a tick box, because none can be split safely", () => { + for (const diagnosis of dsmDiagnoses) { + for (const specifier of dsmSelectableSpecifiers(diagnosis.specifiers)) { + expect(specifier.name).not.toContain(" / "); + } + } + }); + + it("still offers the overwhelming majority of specifiers as tick boxes", () => { + const all = dsmDiagnoses.flatMap((diagnosis) => dsmSpecifierSplit(diagnosis).specifiers); + const selectable = dsmDiagnoses.flatMap((diagnosis) => + dsmSelectableSpecifiers(dsmSpecifierSplit(diagnosis).specifiers), + ); + expect(selectable.length / all.length).toBeGreaterThan(0.9); + }); +}); + +describe("dsmNoteBuilderRecord", () => { + it("does not ship the discarded documentation template to the browser", () => { + // The builder is a Client Component, so anything on its props crosses into + // the RSC payload. Handing it the whole record sent `documentation_template` + // with it — the prose this change exists to stop people pasting — on all 146 + // diagnosis pages. + for (const diagnosis of dsmDiagnoses) { + const serialised = JSON.stringify(dsmNoteBuilderRecord(diagnosis)); + expect(serialised).not.toContain(diagnosis.documentation_template); + } + }); + + it("carries exactly the fields the builder reads", () => { + expect(Object.keys(dsmNoteBuilderRecord(panic)).sort()).toEqual([ + "criteria", + "differentials", + "icdCode", + "specifiers", + "title", + ]); + }); + + it("offers no unsplittable specifier menu through the projection", () => { + for (const diagnosis of dsmDiagnoses) { + for (const specifier of dsmNoteBuilderRecord(diagnosis).specifiers) { + expect(specifier.name).not.toContain(" / "); + } + } + }); +}); diff --git a/tests/in-page-nav-route-sections.dom.test.tsx b/tests/in-page-nav-route-sections.dom.test.tsx index 053b6c402..22334ca1f 100644 --- a/tests/in-page-nav-route-sections.dom.test.tsx +++ b/tests/in-page-nav-route-sections.dom.test.tsx @@ -70,6 +70,13 @@ vi.mock("@/components/account-data-provider", () => ({ vi.mock("@/components/clinical-dashboard/use-medication-catalog", () => ({ useMedicationDetail: () => ({ data: null, loading: false, error: null }), })); +// Same reasoning for the DSM diagnosis page: its cross-mode rail reads the +// owner-scoped service/form registries through AuthProvider, which this file +// does not mount. The rail renders nothing when no mode matches, so stubbing it +// leaves the anchor set under test untouched. +vi.mock("@/components/clinical-dashboard/cross-mode-links", () => ({ + CrossModeLinksSection: () => null, +})); vi.mock("@/components/clinical-dashboard/patient-profile-panel", () => ({ PatientProfilePanel: () => null })); vi.mock("@/components/clinical-dashboard/medication-considerations", () => ({ MedicationConsiderations: () => null, From b25d9f7c43019363d57ef860bf1a4a35a2c63a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:33:52 +0000 Subject: [PATCH 02/11] issues: queue release-browser-matrix redness, main CI cancellations, Lighthouse baseline sequencing Three immutable inbox requests from the DSM diagnosis page session: - release-browser-matrix fails on every main run with 15 Firefox/WebKit failures and is not in PR required, so it never blocks a merge - main CI runs are cancelled during high merge churn despite the per-run concurrency fix, so most main commits land unverified - do not refresh the Lighthouse baseline until main has one genuinely green run, otherwise unattributable drift becomes the new normal No canonical ledger edit; reconciliation runs separately on its own branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- .../3c0b6354-a44c-4003-aa08-a1277f3f7d22.json | 14 ++++++++++++++ .../6b90cb8e-eec5-4bb1-8406-d00e123c8527.json | 14 ++++++++++++++ .../9fcfa105-cf74-462b-9a10-3601894600e0.json | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json create mode 100644 docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json create mode 100644 docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json diff --git a/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json new file mode 100644 index 000000000..8f099054a --- /dev/null +++ b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "3c0b6354-a44c-4003-aa08-a1277f3f7d22", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "Main CI runs are cancelled during high merge churn even though the per-run concurrency fix is in place, so most main commits land unverified", + "detail": "Of the last 8 completed CI runs on main, 6 concluded cancelled and 2 failed; none succeeded. The cancellations are NOT the 2026-08-20 queue-eviction mechanism the ci.yml concurrency comment documents: that fix is present and correct. Verified on commit 19ee948, which carries group: CI-${{ github.run_id }} for push events plus cancel-in-progress: ${{ github.event_name != 'push' }}, so a main push run has a unique group and cannot be superseded or evicted by concurrency. Something outside the workflow is cancelling them. TEMPORAL PATTERN: every run before 22:37 UTC on 2026-09-06 was cancelled; the two after it completed. That boundary coincides with merge churn stopping. Volume context: 132 merge commits to main in 24 hours, 19 in one 3-hour window, against a roughly 65-minute run that includes a roughly 50-minute release-browser-matrix. LEADING HYPOTHESIS, unproven from inside a container: the account is hitting a GitHub Actions spending or concurrency cap and GitHub is cancelling queued runs. Supporting circumstantial evidence: two other services on the same account hit usage limits the same day (Cursor Bugbot 'usage limit reached', Codex 'reached your usage limits for code reviews'). NEXT ACTION: read Actions usage and spending limits in the org settings to confirm or eliminate the cap hypothesis. If confirmed, the choice is to raise the cap or cut per-run cost (the 50-minute browser matrix on every main push is the dominant consumer, and could move to a schedule). If eliminated, capture the true cancellation source before assuming main is verified.", + "source": "session 2026-09-06 diagnosis of main CI; runs 34042148454, 34042968846, 34043792758, 34045811456, 34062428577, 34063663969", + "issueUlid": "01M1XFW8TMYSKD8JMVH02ZTV0C" + } +} diff --git a/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json b/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json new file mode 100644 index 000000000..6a1f5ad25 --- /dev/null +++ b/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "6b90cb8e-eec5-4bb1-8406-d00e123c8527", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "rec", + "summary": "Do not refresh the Lighthouse performance baseline until main has one genuinely green run", + "detail": "The Lighthouse budget cells on the release browser matrix have drifted from their recorded baseline, and the obvious remedy (re-record the baseline from a recent main run) is unsafe right now. All four cells drifted together, which points at an environment/runner-level shift rather than any one PR, and main is not currently in a known-good state because release-browser-matrix fails on every run with 15 Firefox/WebKit failures. Refreshing the baseline from a red main would stamp unattributable drift as 'the new normal' and permanently lose the ability to attribute it. NEXT ACTION, in this order: (1) fix or triage the release-browser-matrix Firefox/WebKit failures, (2) obtain one main run where the whole matrix is genuinely green, (3) only then re-record the Lighthouse baseline from that SHA and note the SHA in the commit message. STOP RULE: if step 2 cannot be reached, do not proceed to step 3 - raise it with the owner instead. Related but distinct: #QSHHGK covers the bundle-budget baseline refresh, which is a different artefact.", + "source": "Session 2026-09-07 (DSM diagnosis page work). Reversal of my own earlier advice in the same session: I first suggested refreshing the baseline, then withdrew that after establishing main is red. Offline analysis plus GitHub Actions run reads; no baseline was changed.", + "issueUlid": "01M1XFZZ550H0S89B5N5XKHAVT" + } +} diff --git a/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json new file mode 100644 index 000000000..bae93d3ad --- /dev/null +++ b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "9fcfa105-cf74-462b-9a10-3601894600e0", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "release-browser-matrix fails on every main run with 15 Firefox/WebKit failures, and it is not in PR required so it never blocks a merge", + "detail": "Evidence, main run 34065301954 on edbd29f (#2690): every other job passed - Static PR checks, Unit coverage, Safety and config, Build, Visual baselines, Caring Contacts, Production UI (1)(2)(3), Lighthouse budget, and PR required itself. The sole failure was release-browser-matrix: '15 failed, 80 skipped, 1397 passed (48.3m)'. The identical single-job failure occurred on the preceding main commit cae0655 (#2689), so it is persistent and independent of the change under test. ALL 15 failures are Firefox (5) or WebKit (10); Chromium had zero. They are viewport and scroll-geometry assertions, e.g. tests/ui-tools.spec.ts:3172 'long mobile service details paint to the viewport edge with no bottom search dock' expected footerBottom <= 820 but received 838.546875 on webkit. Others: ui-formulation-result-cards.spec.ts:35 (both engines), ui-smoke.spec.ts:1202/2939/1648/5171/5961, ui-stress.spec.ts:436, ui-tools.spec.ts:2927, ui-phone-scroll-page-owned.spec.ts:98 (x2), ui-phone-scroll.spec.ts:236, ui-route-coverage.spec.ts:554, ui-specifiers.spec.ts:181 (axe violation). WHY IT IS INVISIBLE: release-browser-matrix is not part of the PR required aggregate - PR required concluded success on edbd29f while the run overall went red - so this paints every main run red without blocking any merge, and trains readers to ignore main's status. Distinct from #055, which is a process task to run the release gate before a handoff; this row is that the matrix is currently and persistently red. NEXT ACTION: triage the 15 as one focused piece of work (they cluster into phone-chrome scroll geometry, document viewer composer, service detail footer clearance, and one specifiers axe violation), then decide whether release-browser-matrix should join PR required or gain an explicit non-blocking label so its red state is honest rather than ambient.", + "source": "session 2026-09-06 diagnosis of main CI; runs 34065301954 and 34064564709", + "issueUlid": "01M1XFVVAZ9Z197J7AAJJEP4C7" + } +} From 30e59657652bb7246c71e6d1737dab6285f495bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:33:52 +0000 Subject: [PATCH 03/11] issues: queue release-browser-matrix redness, main CI cancellations, Lighthouse baseline sequencing Three immutable inbox requests from the DSM diagnosis page session: - release-browser-matrix fails on every main run with 15 Firefox/WebKit failures and is not in PR required, so it never blocks a merge - main CI runs are cancelled during high merge churn despite the per-run concurrency fix, so most main commits land unverified - do not refresh the Lighthouse baseline until main has one genuinely green run, otherwise unattributable drift becomes the new normal No canonical ledger edit; reconciliation runs separately on its own branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- .../3c0b6354-a44c-4003-aa08-a1277f3f7d22.json | 14 ++++++++++++++ .../6b90cb8e-eec5-4bb1-8406-d00e123c8527.json | 14 ++++++++++++++ .../9fcfa105-cf74-462b-9a10-3601894600e0.json | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json create mode 100644 docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json create mode 100644 docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json diff --git a/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json new file mode 100644 index 000000000..8f099054a --- /dev/null +++ b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "3c0b6354-a44c-4003-aa08-a1277f3f7d22", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "Main CI runs are cancelled during high merge churn even though the per-run concurrency fix is in place, so most main commits land unverified", + "detail": "Of the last 8 completed CI runs on main, 6 concluded cancelled and 2 failed; none succeeded. The cancellations are NOT the 2026-08-20 queue-eviction mechanism the ci.yml concurrency comment documents: that fix is present and correct. Verified on commit 19ee948, which carries group: CI-${{ github.run_id }} for push events plus cancel-in-progress: ${{ github.event_name != 'push' }}, so a main push run has a unique group and cannot be superseded or evicted by concurrency. Something outside the workflow is cancelling them. TEMPORAL PATTERN: every run before 22:37 UTC on 2026-09-06 was cancelled; the two after it completed. That boundary coincides with merge churn stopping. Volume context: 132 merge commits to main in 24 hours, 19 in one 3-hour window, against a roughly 65-minute run that includes a roughly 50-minute release-browser-matrix. LEADING HYPOTHESIS, unproven from inside a container: the account is hitting a GitHub Actions spending or concurrency cap and GitHub is cancelling queued runs. Supporting circumstantial evidence: two other services on the same account hit usage limits the same day (Cursor Bugbot 'usage limit reached', Codex 'reached your usage limits for code reviews'). NEXT ACTION: read Actions usage and spending limits in the org settings to confirm or eliminate the cap hypothesis. If confirmed, the choice is to raise the cap or cut per-run cost (the 50-minute browser matrix on every main push is the dominant consumer, and could move to a schedule). If eliminated, capture the true cancellation source before assuming main is verified.", + "source": "session 2026-09-06 diagnosis of main CI; runs 34042148454, 34042968846, 34043792758, 34045811456, 34062428577, 34063663969", + "issueUlid": "01M1XFW8TMYSKD8JMVH02ZTV0C" + } +} diff --git a/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json b/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json new file mode 100644 index 000000000..6a1f5ad25 --- /dev/null +++ b/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "6b90cb8e-eec5-4bb1-8406-d00e123c8527", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "rec", + "summary": "Do not refresh the Lighthouse performance baseline until main has one genuinely green run", + "detail": "The Lighthouse budget cells on the release browser matrix have drifted from their recorded baseline, and the obvious remedy (re-record the baseline from a recent main run) is unsafe right now. All four cells drifted together, which points at an environment/runner-level shift rather than any one PR, and main is not currently in a known-good state because release-browser-matrix fails on every run with 15 Firefox/WebKit failures. Refreshing the baseline from a red main would stamp unattributable drift as 'the new normal' and permanently lose the ability to attribute it. NEXT ACTION, in this order: (1) fix or triage the release-browser-matrix Firefox/WebKit failures, (2) obtain one main run where the whole matrix is genuinely green, (3) only then re-record the Lighthouse baseline from that SHA and note the SHA in the commit message. STOP RULE: if step 2 cannot be reached, do not proceed to step 3 - raise it with the owner instead. Related but distinct: #QSHHGK covers the bundle-budget baseline refresh, which is a different artefact.", + "source": "Session 2026-09-07 (DSM diagnosis page work). Reversal of my own earlier advice in the same session: I first suggested refreshing the baseline, then withdrew that after establishing main is red. Offline analysis plus GitHub Actions run reads; no baseline was changed.", + "issueUlid": "01M1XFZZ550H0S89B5N5XKHAVT" + } +} diff --git a/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json new file mode 100644 index 000000000..bae93d3ad --- /dev/null +++ b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "9fcfa105-cf74-462b-9a10-3601894600e0", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "release-browser-matrix fails on every main run with 15 Firefox/WebKit failures, and it is not in PR required so it never blocks a merge", + "detail": "Evidence, main run 34065301954 on edbd29f (#2690): every other job passed - Static PR checks, Unit coverage, Safety and config, Build, Visual baselines, Caring Contacts, Production UI (1)(2)(3), Lighthouse budget, and PR required itself. The sole failure was release-browser-matrix: '15 failed, 80 skipped, 1397 passed (48.3m)'. The identical single-job failure occurred on the preceding main commit cae0655 (#2689), so it is persistent and independent of the change under test. ALL 15 failures are Firefox (5) or WebKit (10); Chromium had zero. They are viewport and scroll-geometry assertions, e.g. tests/ui-tools.spec.ts:3172 'long mobile service details paint to the viewport edge with no bottom search dock' expected footerBottom <= 820 but received 838.546875 on webkit. Others: ui-formulation-result-cards.spec.ts:35 (both engines), ui-smoke.spec.ts:1202/2939/1648/5171/5961, ui-stress.spec.ts:436, ui-tools.spec.ts:2927, ui-phone-scroll-page-owned.spec.ts:98 (x2), ui-phone-scroll.spec.ts:236, ui-route-coverage.spec.ts:554, ui-specifiers.spec.ts:181 (axe violation). WHY IT IS INVISIBLE: release-browser-matrix is not part of the PR required aggregate - PR required concluded success on edbd29f while the run overall went red - so this paints every main run red without blocking any merge, and trains readers to ignore main's status. Distinct from #055, which is a process task to run the release gate before a handoff; this row is that the matrix is currently and persistently red. NEXT ACTION: triage the 15 as one focused piece of work (they cluster into phone-chrome scroll geometry, document viewer composer, service detail footer clearance, and one specifiers axe violation), then decide whether release-browser-matrix should join PR required or gain an explicit non-blocking label so its red state is honest rather than ambient.", + "source": "session 2026-09-06 diagnosis of main CI; runs 34065301954 and 34064564709", + "issueUlid": "01M1XFVVAZ9Z197J7AAJJEP4C7" + } +} From 6e871f12d2d33a23f248d4ce92c587741c614cee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:13:19 +0000 Subject: [PATCH 04/11] issues: correct the ineffective PR-required remedy in the browser-matrix request Codex review on #2713 was right. The queued request proposed deciding whether release-browser-matrix should join PR required, but that remedy cannot work as written: - ci.yml gates release-browser-matrix on workflow_dispatch, schedule, refs/heads/release/*, or refs/heads/main, so it never runs on a pull_request event. - pr-required's needs list does not include it, and the comment above the job records that it must deliberately not wait on pr-required (#023). A needs edit alone would aggregate a skipped job. The corrected record points at fixing the post-merge signal instead, and states that making the matrix genuinely merge-blocking is a separate, much larger workflow change that must be costed on its own. The request has not landed on main, so this correction is a change to unmerged content rather than a mutation of a queued record in the base. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- .../9fcfa105-cf74-462b-9a10-3601894600e0.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json index bae93d3ad..0ec2e4ade 100644 --- a/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json +++ b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json @@ -7,7 +7,7 @@ "pri": "P2", "type": "issue", "summary": "release-browser-matrix fails on every main run with 15 Firefox/WebKit failures, and it is not in PR required so it never blocks a merge", - "detail": "Evidence, main run 34065301954 on edbd29f (#2690): every other job passed - Static PR checks, Unit coverage, Safety and config, Build, Visual baselines, Caring Contacts, Production UI (1)(2)(3), Lighthouse budget, and PR required itself. The sole failure was release-browser-matrix: '15 failed, 80 skipped, 1397 passed (48.3m)'. The identical single-job failure occurred on the preceding main commit cae0655 (#2689), so it is persistent and independent of the change under test. ALL 15 failures are Firefox (5) or WebKit (10); Chromium had zero. They are viewport and scroll-geometry assertions, e.g. tests/ui-tools.spec.ts:3172 'long mobile service details paint to the viewport edge with no bottom search dock' expected footerBottom <= 820 but received 838.546875 on webkit. Others: ui-formulation-result-cards.spec.ts:35 (both engines), ui-smoke.spec.ts:1202/2939/1648/5171/5961, ui-stress.spec.ts:436, ui-tools.spec.ts:2927, ui-phone-scroll-page-owned.spec.ts:98 (x2), ui-phone-scroll.spec.ts:236, ui-route-coverage.spec.ts:554, ui-specifiers.spec.ts:181 (axe violation). WHY IT IS INVISIBLE: release-browser-matrix is not part of the PR required aggregate - PR required concluded success on edbd29f while the run overall went red - so this paints every main run red without blocking any merge, and trains readers to ignore main's status. Distinct from #055, which is a process task to run the release gate before a handoff; this row is that the matrix is currently and persistently red. NEXT ACTION: triage the 15 as one focused piece of work (they cluster into phone-chrome scroll geometry, document viewer composer, service detail footer clearance, and one specifiers axe violation), then decide whether release-browser-matrix should join PR required or gain an explicit non-blocking label so its red state is honest rather than ambient.", + "detail": "Evidence, main run 34065301954 on edbd29f (#2690): every other job passed - Static PR checks, Unit coverage, Safety and config, Build, Visual baselines, Caring Contacts, Production UI (1)(2)(3), Lighthouse budget, and PR required itself. The sole failure was release-browser-matrix: '15 failed, 80 skipped, 1397 passed (48.3m)'. The identical single-job failure occurred on the preceding main commit cae0655 (#2689), so it is persistent and independent of the change under test. ALL 15 failures are Firefox (5) or WebKit (10); Chromium had zero. They are viewport and scroll-geometry assertions, e.g. tests/ui-tools.spec.ts:3172 'long mobile service details paint to the viewport edge with no bottom search dock' expected footerBottom <= 820 but received 838.546875 on webkit. Others: ui-formulation-result-cards.spec.ts:35 (both engines), ui-smoke.spec.ts:1202/2939/1648/5171/5961, ui-stress.spec.ts:436, ui-tools.spec.ts:2927, ui-phone-scroll-page-owned.spec.ts:98 (x2), ui-phone-scroll.spec.ts:236, ui-route-coverage.spec.ts:554, ui-specifiers.spec.ts:181 (axe violation). WHY IT IS INVISIBLE: release-browser-matrix is not part of the PR required aggregate - PR required concluded success on edbd29f while the run overall went red - so this paints every main run red without blocking any merge, and trains readers to ignore main's status. Distinct from #055, which is a process task to run the release gate before a handoff; this row is that the matrix is currently and persistently red. NEXT ACTION: triage the 15 as one focused piece of work (they cluster into phone-chrome scroll geometry, document viewer composer, service detail footer clearance, and one specifiers axe violation). VISIBILITY REMEDY, corrected 2026-09-07 after review: do NOT simply add release-browser-matrix to the pr-required needs list. ci.yml gates the job on workflow_dispatch, schedule, refs/heads/release/*, or refs/heads/main only, so it never runs on a pull_request event at all, and the comment above it records that it must deliberately not wait on pr-required (#023 - a blocking weekly dependency audit once skipped the matrix entirely). A needs edit alone would aggregate a skipped job and change nothing. Fix the post-merge signal instead so a red matrix on main is visible and owned rather than ambient. Making it genuinely merge-blocking is a separate and much larger change - a pull_request trigger plus an aggregate redesign that runs a roughly 50 minute matrix before every merge - and must be costed and decided on its own rather than assumed here.", "source": "session 2026-09-06 diagnosis of main CI; runs 34065301954 and 34064564709", "issueUlid": "01M1XFVVAZ9Z197J7AAJJEP4C7" } From eeecbbe2053cc3243403b32e3c13a478d8b2326a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:41:24 +0000 Subject: [PATCH 05/11] issues: correct the main-CI cancellation mechanism, it is a job timeout not a quota cap The queued request blamed a GitHub Actions spending or concurrency cap for main runs concluding cancelled. Job-level timing disproves that. release-browser-matrix carries timeout-minutes: 70. Measured on two consecutive main runs: - run 34100540973 (f3ea7cb) job 101675121681: 08:30:56Z to 09:41:19Z = 70m23s - run 34104496596 (c8cc72f) job 101689976803: 09:22:47Z to 10:33:07Z = 70m20s Both land exactly on the configured timeout, and their "Full browser UI matrix" step ends in conclusion cancelled. GitHub reports a timed-out job as cancelled, and one cancelled job makes the whole run cancelled. Every other job succeeded in both runs, PR required included. The consequence is worse than first recorded: the matrix now produces no result rather than a red one, so main's Firefox and WebKit coverage is zero rather than failing. The sibling request's 15 failures describe the last state in which the job still finished, at 48.3m, and are cross-linked as superseded in part. The next action is to find why the suite went from roughly 48 to over 70 minutes, not to raise the timeout, which would buy a longer run without restoring a verdict. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- .../3c0b6354-a44c-4003-aa08-a1277f3f7d22.json | 6 +++--- .../9fcfa105-cf74-462b-9a10-3601894600e0.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json index 8f099054a..98ec40f94 100644 --- a/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json +++ b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json @@ -6,9 +6,9 @@ "payload": { "pri": "P2", "type": "issue", - "summary": "Main CI runs are cancelled during high merge churn even though the per-run concurrency fix is in place, so most main commits land unverified", - "detail": "Of the last 8 completed CI runs on main, 6 concluded cancelled and 2 failed; none succeeded. The cancellations are NOT the 2026-08-20 queue-eviction mechanism the ci.yml concurrency comment documents: that fix is present and correct. Verified on commit 19ee948, which carries group: CI-${{ github.run_id }} for push events plus cancel-in-progress: ${{ github.event_name != 'push' }}, so a main push run has a unique group and cannot be superseded or evicted by concurrency. Something outside the workflow is cancelling them. TEMPORAL PATTERN: every run before 22:37 UTC on 2026-09-06 was cancelled; the two after it completed. That boundary coincides with merge churn stopping. Volume context: 132 merge commits to main in 24 hours, 19 in one 3-hour window, against a roughly 65-minute run that includes a roughly 50-minute release-browser-matrix. LEADING HYPOTHESIS, unproven from inside a container: the account is hitting a GitHub Actions spending or concurrency cap and GitHub is cancelling queued runs. Supporting circumstantial evidence: two other services on the same account hit usage limits the same day (Cursor Bugbot 'usage limit reached', Codex 'reached your usage limits for code reviews'). NEXT ACTION: read Actions usage and spending limits in the org settings to confirm or eliminate the cap hypothesis. If confirmed, the choice is to raise the cap or cut per-run cost (the 50-minute browser matrix on every main push is the dominant consumer, and could move to a schedule). If eliminated, capture the true cancellation source before assuming main is verified.", - "source": "session 2026-09-06 diagnosis of main CI; runs 34042148454, 34042968846, 34043792758, 34045811456, 34062428577, 34063663969", + "summary": "Every main CI run concludes cancelled because release-browser-matrix hits its own 70 minute timeout, so main has had no browser coverage at all", + "detail": "MECHANISM IDENTIFIED 2026-09-07 by job-level timing, and it is NOT the spending or concurrency cap this row originally hypothesised. That hypothesis is withdrawn, and the per-run concurrency group is not implicated. release-browser-matrix carries timeout-minutes: 70 (.github/workflows/ci.yml). Measured on two consecutive main runs: run 34100540973 (f3ea7cb), job 101675121681, started 08:30:56Z and completed 09:41:19Z = 70m23s, with its 'Full browser UI matrix' step ending in conclusion cancelled. Run 34104496596 (c8cc72f), job 101689976803, started 09:22:47Z and completed 10:33:07Z = 70m20s, identical shape. Both land exactly on the configured timeout. GitHub reports a timed-out job as cancelled, and one cancelled job makes the whole run conclusion cancelled. That is the entire explanation for main's run-level redness, and it also explains the earlier temporal pattern (runs stopped concluding cancelled when the merge queue quietened and the matrix presumably completed inside 70 minutes). In both runs every other job succeeded, PR required included: Change scope, Static PR checks, Unit coverage, Build, Safety and config, Caring Contacts database, Production UI (1)(2)(3), Visual baselines, Lighthouse budget. CONSEQUENCE, and it is worse than the original row implied: the matrix now produces NO result rather than a red one. Main's Firefox and WebKit coverage is currently zero, not failing. The sibling row recording 15 Firefox/WebKit failures describes the last state in which the matrix still finished, at 48.3m on edbd29f. It has since crossed 70 minutes, so those 15 failures are no longer even being reported. NEXT ACTION: find why the matrix went from roughly 48 minutes to over 70. The first candidate is the 15 failing tests themselves, because a failing Playwright test spends its full timeout and then pays for trace and video capture, so failures are disproportionately expensive and a growing failure set is self-accelerating. Fix the failures first and re-measure. Do NOT simply raise timeout-minutes: that buys a longer run without restoring a verdict, and it hides the regression that made the suite slower. If the suite is genuinely too long after the failures are fixed, shard it the way Production UI is already sharded into three jobs rather than extending one 70 minute job. ORIGINAL EVIDENCE, retained: of the 8 completed CI runs on main sampled 2026-09-06, 6 concluded cancelled and 2 failed, none succeeded. Volume context: 132 merge commits to main in 24 hours, 19 in one 3-hour window. The 2026-08-20 queue-eviction mechanism was ruled out and stays ruled out: commit 19ee948 carries group: CI-${{ github.run_id }} for push events plus cancel-in-progress: ${{ github.event_name != 'push' }}, so a main push run has a unique group and cannot be superseded.", + "source": "Session 2026-09-07. Original hypothesis from run-level reads on 2026-09-06; corrected the same day by job-level timing on runs 34100540973 and 34104496596, which both show release-browser-matrix ending at its 70 minute timeout. Offline repo read of ci.yml plus GitHub Actions job reads. No provider mutation.", "issueUlid": "01M1XFW8TMYSKD8JMVH02ZTV0C" } } diff --git a/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json index 0ec2e4ade..39111df38 100644 --- a/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json +++ b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json @@ -7,7 +7,7 @@ "pri": "P2", "type": "issue", "summary": "release-browser-matrix fails on every main run with 15 Firefox/WebKit failures, and it is not in PR required so it never blocks a merge", - "detail": "Evidence, main run 34065301954 on edbd29f (#2690): every other job passed - Static PR checks, Unit coverage, Safety and config, Build, Visual baselines, Caring Contacts, Production UI (1)(2)(3), Lighthouse budget, and PR required itself. The sole failure was release-browser-matrix: '15 failed, 80 skipped, 1397 passed (48.3m)'. The identical single-job failure occurred on the preceding main commit cae0655 (#2689), so it is persistent and independent of the change under test. ALL 15 failures are Firefox (5) or WebKit (10); Chromium had zero. They are viewport and scroll-geometry assertions, e.g. tests/ui-tools.spec.ts:3172 'long mobile service details paint to the viewport edge with no bottom search dock' expected footerBottom <= 820 but received 838.546875 on webkit. Others: ui-formulation-result-cards.spec.ts:35 (both engines), ui-smoke.spec.ts:1202/2939/1648/5171/5961, ui-stress.spec.ts:436, ui-tools.spec.ts:2927, ui-phone-scroll-page-owned.spec.ts:98 (x2), ui-phone-scroll.spec.ts:236, ui-route-coverage.spec.ts:554, ui-specifiers.spec.ts:181 (axe violation). WHY IT IS INVISIBLE: release-browser-matrix is not part of the PR required aggregate - PR required concluded success on edbd29f while the run overall went red - so this paints every main run red without blocking any merge, and trains readers to ignore main's status. Distinct from #055, which is a process task to run the release gate before a handoff; this row is that the matrix is currently and persistently red. NEXT ACTION: triage the 15 as one focused piece of work (they cluster into phone-chrome scroll geometry, document viewer composer, service detail footer clearance, and one specifiers axe violation). VISIBILITY REMEDY, corrected 2026-09-07 after review: do NOT simply add release-browser-matrix to the pr-required needs list. ci.yml gates the job on workflow_dispatch, schedule, refs/heads/release/*, or refs/heads/main only, so it never runs on a pull_request event at all, and the comment above it records that it must deliberately not wait on pr-required (#023 - a blocking weekly dependency audit once skipped the matrix entirely). A needs edit alone would aggregate a skipped job and change nothing. Fix the post-merge signal instead so a red matrix on main is visible and owned rather than ambient. Making it genuinely merge-blocking is a separate and much larger change - a pull_request trigger plus an aggregate redesign that runs a roughly 50 minute matrix before every merge - and must be costed and decided on its own rather than assumed here.", + "detail": "Evidence, main run 34065301954 on edbd29f (#2690): every other job passed - Static PR checks, Unit coverage, Safety and config, Build, Visual baselines, Caring Contacts, Production UI (1)(2)(3), Lighthouse budget, and PR required itself. The sole failure was release-browser-matrix: '15 failed, 80 skipped, 1397 passed (48.3m)'. The identical single-job failure occurred on the preceding main commit cae0655 (#2689), so it is persistent and independent of the change under test. ALL 15 failures are Firefox (5) or WebKit (10); Chromium had zero. They are viewport and scroll-geometry assertions, e.g. tests/ui-tools.spec.ts:3172 'long mobile service details paint to the viewport edge with no bottom search dock' expected footerBottom <= 820 but received 838.546875 on webkit. Others: ui-formulation-result-cards.spec.ts:35 (both engines), ui-smoke.spec.ts:1202/2939/1648/5171/5961, ui-stress.spec.ts:436, ui-tools.spec.ts:2927, ui-phone-scroll-page-owned.spec.ts:98 (x2), ui-phone-scroll.spec.ts:236, ui-route-coverage.spec.ts:554, ui-specifiers.spec.ts:181 (axe violation). WHY IT IS INVISIBLE: release-browser-matrix is not part of the PR required aggregate - PR required concluded success on edbd29f while the run overall went red - so this paints every main run red without blocking any merge, and trains readers to ignore main's status. Distinct from #055, which is a process task to run the release gate before a handoff; this row is that the matrix is currently and persistently red. NEXT ACTION: triage the 15 as one focused piece of work (they cluster into phone-chrome scroll geometry, document viewer composer, service detail footer clearance, and one specifiers axe violation). VISIBILITY REMEDY, corrected 2026-09-07 after review: do NOT simply add release-browser-matrix to the pr-required needs list. ci.yml gates the job on workflow_dispatch, schedule, refs/heads/release/*, or refs/heads/main only, so it never runs on a pull_request event at all, and the comment above it records that it must deliberately not wait on pr-required (#023 - a blocking weekly dependency audit once skipped the matrix entirely). A needs edit alone would aggregate a skipped job and change nothing. Fix the post-merge signal instead so a red matrix on main is visible and owned rather than ambient. Making it genuinely merge-blocking is a separate and much larger change - a pull_request trigger plus an aggregate redesign that runs a roughly 50 minute matrix before every merge - and must be costed and decided on its own rather than assumed here. SUPERSEDED IN PART 2026-09-07: the matrix no longer reports these 15 failures at all. It now exceeds its 70 minute timeout on every main run and is recorded as cancelled, so main's Firefox and WebKit coverage is currently zero rather than red. The 48.3m/15-failure evidence above is the last state in which the job still finished. Fix the timeout cause first (see the sibling row on release-browser-matrix timing out), because the 15 failures cannot be re-measured until the job completes again.", "source": "session 2026-09-06 diagnosis of main CI; runs 34065301954 and 34064564709", "issueUlid": "01M1XFVVAZ9Z197J7AAJJEP4C7" } From 931cab455fd15e98504e64c123a43dd380ebf5eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:54:10 +0000 Subject: [PATCH 06/11] ci: split release-browser-matrix by engine so it finishes and reports a verdict The job stopped finishing. Measured at job level on two consecutive main runs: run 34100540973 job 101675121681 ran 70m23s, run 34104496596 job 101689976803 ran 70m20s, both landing exactly on the old timeout-minutes: 70. GitHub reports a timed-out job as cancelled, and one cancelled job makes the whole run cancelled, which is why every main run concluded cancelled while every other job passed. The consequence was worse than a red suite: the matrix produced NO verdict, so main had zero Firefox and WebKit coverage rather than failing coverage. The 15 Firefox/WebKit failures last seen on edbd29f at 48.3m have not been reported since. Cause: playwright.config.ts pins workers: 1 and fullyParallel: false for determinism, so chromium-mockups + firefox + webkit ran strictly end to end in one job. With retries: 0 and a 60s per-test timeout, each of the 15 failures spends its full timeout, which is roughly the 48-to-70 minute gap on its own. Fix: run the engines as sibling matrix jobs, the same answer Production UI already uses for its Chromium shards. Wall-clock becomes the slowest engine rather than the sum, and determinism inside each job is untouched. The timeout is LOWERED to 45 rather than raised: a single-worker suite is not made faster by a longer cap, and raising it would restore the defect. The split introduces one hazard a single job did not have. The fail-safe path ran bare `npm run test:e2e`, meaning every project in playwright.config.ts; spread across engines that set is now written out, so a project added later could silently never run. tests/ci-browser-matrix-coverage.test.ts re-derives both branch unions from ci.yml and the project list from playwright.config.ts and fails closed on any drift, duplicate, or omission. No test is skipped, disabled or quarantined by this change, and the 15 known failures will now be reported again rather than hidden behind a timeout. The ci-cache-safety case that pinned the single-job command is updated, not removed: it still owns the property it always owned, that the primary path does not re-run production Chromium which ui-critical already proved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- .github/workflows/ci.yml | 61 +++++++++++- package.json | 2 +- tests/ci-browser-matrix-coverage.test.ts | 117 +++++++++++++++++++++++ tests/ci-cache-safety.test.ts | 15 ++- 4 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 tests/ci-browser-matrix-coverage.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3efd0154..a2168aade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1558,7 +1558,32 @@ jobs: (needs.ui-critical.result == 'success' || needs.ui-critical.result == 'skipped') needs: [changes, static-pr, build, ui-critical] runs-on: ubuntu-24.04 - timeout-minutes: 70 + # Per ENGINE, not for the whole matrix. Before 2026-09-07 one job ran + # chromium-mockups + firefox + webkit sequentially under `workers: 1` / + # `fullyParallel: false`, and it stopped finishing: measured 70m23s + # (run 34100540973) and 70m20s (run 34104496596), both landing exactly on the + # old 70-minute cap. GitHub reports a timed-out job as `cancelled`, so every + # main run concluded cancelled while every other job passed — and the matrix + # produced NO verdict, which is worse than a red one. `main` had zero + # Firefox/WebKit coverage rather than failing coverage. + # + # A single-worker suite is not made faster by a longer cap, so the cap was + # NOT raised. The engines run as sibling jobs instead, the same answer + # `Production UI` already uses for Chromium (see its shard strategy above), + # which keeps `fullyParallel: false` / `retries: 0` determinism inside each + # job while cutting wall-clock to the slowest engine rather than their sum. + timeout-minutes: 45 + strategy: + # One engine's failure must not cancel its siblings: a cancelled sibling + # reports no verdict, which is the exact failure mode this change exists to + # remove. + fail-fast: false + matrix: + # Groups, not bare project names, because the fail-safe path below runs + # the COMPLETE project set and every project must land in exactly one + # group. `tests/ci-browser-matrix-coverage.test.ts` proves that union + # against playwright.config.ts and fails closed when a project is added. + engine: [chromium, firefox, webkit] steps: - name: Checkout @@ -1610,21 +1635,47 @@ jobs: - name: Full browser UI matrix id: e2e-matrix + env: + ENGINE: ${{ matrix.engine }} run: | + # Both paths below are exhaustive across playwright.config.ts projects + # for their own path, and disjoint between engines. The fail-safe union + # must equal the COMPLETE project set — a project omitted here would + # vanish from CI silently, so + # `tests/ci-browser-matrix-coverage.test.ts` re-derives both unions from + # this file plus playwright.config.ts and fails closed on any drift. if [ "${{ needs.changes.outputs.ui_changed }}" = "true" ] && [ "${{ needs.ui-critical.result }}" = "success" ]; then # Production Chromium already passed in this run. Keep mockup # Chromium plus the cross-browser backstop without repeating it. - npm run test:e2e -- --project=chromium-mockups --project=firefox --project=webkit + case "$ENGINE" in + chromium) PROJECTS="--project=chromium-mockups" ;; + firefox) PROJECTS="--project=firefox" ;; + webkit) PROJECTS="--project=webkit" ;; + *) echo "::error::unknown engine '$ENGINE'" >&2; exit 1 ;; + esac else - # Fail-safe path for perf/lockfile-only or otherwise skipped UI proof. - npm run test:e2e + # Fail-safe path for perf/lockfile-only or otherwise skipped UI proof: + # this is where the complete project set has to be accounted for. + case "$ENGINE" in + chromium) PROJECTS="--project=chromium --project=chromium-mockups --project=chromium-caring-contacts-seeded" ;; + firefox) PROJECTS="--project=firefox" ;; + webkit) PROJECTS="--project=webkit --project=mobile-webkit --project=mobile-pwa-standalone" ;; + *) echo "::error::unknown engine '$ENGINE'" >&2; exit 1 ;; + esac fi + echo "Running $ENGINE projects: $PROJECTS" + # Word-splitting is intended: $PROJECTS is a list of --project= flags + # built above, never user input. + # shellcheck disable=SC2086 + npm run test:e2e -- $PROJECTS - name: Upload UI diagnostics if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-ui-diagnostics-${{ github.run_id }} + # Per engine: a shared name makes the second failing engine fail on + # upload, the same trap already documented on the Production UI shards. + name: release-ui-diagnostics-${{ github.run_id }}-${{ matrix.engine }} path: | test-results/ playwright-report/ diff --git a/package.json b/package.json index d225f6ede..dd82a2c22 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "test:coverage": "node scripts/run-vitest.mjs run --coverage", "test:coverage:node": "node scripts/run-vitest.mjs run --project=node --coverage", "test:coverage:ui": "node scripts/run-vitest.mjs run --project=jsdom --coverage", - "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/ci-audit-contracts.test.ts tests/branch-review-index.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/chain-mirror-parity.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/live-domain-monitor-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/reindex-reaper-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts tests/bundle-budget-refresh-workflow.test.ts", + "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/ci-audit-contracts.test.ts tests/ci-browser-matrix-coverage.test.ts tests/branch-review-index.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/chain-mirror-parity.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/live-domain-monitor-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/reindex-reaper-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts tests/bundle-budget-refresh-workflow.test.ts", "test:cc-guards": "node scripts/run-vitest.mjs run --reporter=dot tests/caring-contacts-plan-draft.dom.test.tsx tests/caring-contacts-plan-patient-detail.test.ts tests/caring-contacts-plan-activation.test.ts tests/caring-contacts-plan-wizard.dom.test.tsx tests/caring-contacts-schedule.test.ts tests/caring-contacts-schedule-view.test.ts tests/caring-contacts-schedule-route.test.ts tests/caring-contacts-schedule-screen.dom.test.tsx tests/caring-contacts-schedule-page.dom.test.tsx tests/caring-contacts-clock.test.ts tests/caring-contacts-new-plan-page.dom.test.tsx tests/caring-contacts-explained-automation.dom.test.tsx tests/caring-contacts-workspace-shell.dom.test.tsx tests/caring-contacts-patients-directory.dom.test.tsx tests/caring-contacts-patient-overview.dom.test.tsx tests/caring-contacts-patients-page.dom.test.tsx tests/caring-contacts-domain-isolation.test.ts tests/caring-contacts-interface-vocabulary.test.ts tests/caring-contacts-retention.test.ts tests/caring-contacts-repository.test.ts tests/caring-contacts-overlay-definitions.test.ts tests/caring-contacts-overlay-trigger-inventory.test.ts tests/caring-contacts-workspace-screens.test.ts tests/route-reachability.test.ts tests/design-system-adoption.test.ts tests/caring-contacts-contact-time-adjustment.dom.test.tsx tests/caring-contacts-contact-route.test.ts tests/caring-contacts-overlay-trigger.dom.test.tsx tests/caring-contacts-overlay-host.dom.test.tsx tests/source-control-bytes.test.ts tests/caring-contacts-demo-seed.test.ts tests/caring-contacts-pathway-versions.test.ts tests/caring-contacts-templates-library.dom.test.tsx tests/caring-contacts-templates-page.dom.test.tsx tests/caring-contacts-template-detail.dom.test.tsx tests/caring-contacts-template-detail-page.dom.test.tsx tests/caring-contacts-reporting.test.ts tests/caring-contacts-guidance-reports-pages.dom.test.tsx tests/caring-contacts-team-workload.test.ts tests/caring-contacts-team-route.test.ts tests/caring-contacts-team-roster.dom.test.tsx tests/caring-contacts-team-page.dom.test.tsx", "test:e2e": "node scripts/run-playwright.mjs", "test:e2e:all": "node scripts/run-playwright.mjs", diff --git a/tests/ci-browser-matrix-coverage.test.ts b/tests/ci-browser-matrix-coverage.test.ts new file mode 100644 index 000000000..7f9251055 --- /dev/null +++ b/tests/ci-browser-matrix-coverage.test.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { sourceFrom } from "./helpers/source-contract"; + +/* + * `release-browser-matrix` runs one job per browser engine. + * + * Why that shape needs a guard of its own: before 2026-09-07 the job ran + * chromium-mockups + firefox + webkit sequentially in ONE job under + * `workers: 1` / `fullyParallel: false`, and it stopped finishing — measured + * 70m23s (run 34100540973) and 70m20s (run 34104496596), both landing exactly on + * the then-70-minute cap. GitHub reports a timed-out job as `cancelled`, so + * every main run concluded cancelled while every other job passed, and the + * matrix produced NO verdict at all. `main` had zero Firefox/WebKit coverage + * rather than failing coverage, which is the strictly worse state and the one + * nobody notices. + * + * Splitting by engine fixes the wall-clock. It also introduces the one hazard + * that a single job did not have: the fail-safe path used to run bare + * `npm run test:e2e`, which means EVERY project in playwright.config.ts. Spread + * across engines, that set is now written out by hand, so a project added to the + * config later would simply never run — and nothing would go red to say so. + * + * These cases exist to make that impossible. They re-derive the engine groups + * from ci.yml and the project list from playwright.config.ts, and fail closed on + * any divergence. Do not relax them to accommodate a new project; add the + * project to the right engine group instead. + */ + +const workflow = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); +const playwrightConfig = readFileSync(new URL("../playwright.config.ts", import.meta.url), "utf8"); + +const releaseJob = sourceFrom(workflow, " release-browser-matrix:", { + label: "release-browser-matrix job definition", +}); + +/** Every project name declared in playwright.config.ts. */ +function configuredProjects(): string[] { + const names = [...playwrightConfig.matchAll(/^\s*name:\s*"([^"]+)"/gm)].map((match) => match[1]); + expect(names.length, "playwright.config.ts declared no projects — the regex above has drifted").toBeGreaterThan(0); + return names; +} + +/** + * The `--project=` flags each engine runs, split by the two branches of the + * step's `if`. The first `case` block is the primary path (production Chromium + * already proven in this run); the second is the fail-safe path that must cover + * everything. + */ +function engineProjects(branch: "primary" | "failsafe"): Map { + const caseBlocks = [...releaseJob.matchAll(/case "\$ENGINE" in\n([\s\S]*?)\n\s*esac/g)].map((match) => match[1]); + expect(caseBlocks.length, 'expected exactly two `case "$ENGINE"` blocks in the matrix step').toBe(2); + + const block = caseBlocks[branch === "primary" ? 0 : 1]; + const byEngine = new Map(); + for (const line of block.split("\n")) { + const engine = /^\s*([a-z]+)\)\s+PROJECTS=/.exec(line); + if (!engine) continue; + byEngine.set( + engine[1], + [...line.matchAll(/--project=([\w-]+)/g)].map((match) => match[1]), + ); + } + return byEngine; +} + +describe("release-browser-matrix engine coverage", () => { + it("declares one job per engine and does not buy time with a longer timeout", () => { + expect(releaseJob).toContain("engine: [chromium, firefox, webkit]"); + expect(releaseJob).toContain("fail-fast: false"); + + // A single-worker suite is not made faster by a longer cap. Raising this + // back above the old 70 restores the exact defect: a job that runs for over + // an hour and then reports nothing. + const timeout = /timeout-minutes:\s*(\d+)/.exec(releaseJob); + expect(timeout, "release-browser-matrix must declare a timeout").not.toBeNull(); + expect(Number(timeout?.[1])).toBeLessThanOrEqual(45); + }); + + it("runs every configured Playwright project on the fail-safe path", () => { + const byEngine = engineProjects("failsafe"); + expect([...byEngine.keys()].sort()).toEqual(["chromium", "firefox", "webkit"]); + + const covered = [...byEngine.values()].flat(); + expect( + [...covered].sort(), + "a playwright.config.ts project is missing from the fail-safe engine groups, so it would never run in CI", + ).toEqual([...configuredProjects()].sort()); + }); + + it("never runs the same project on two engines", () => { + for (const branch of ["primary", "failsafe"] as const) { + const covered = [...engineProjects(branch).values()].flat(); + expect(new Set(covered).size, `${branch} path runs a project on more than one engine`).toBe(covered.length); + } + }); + + it("keeps the primary path skipping production Chromium already proven in the run", () => { + const byEngine = engineProjects("primary"); + expect([...byEngine.values()].flat().sort()).toEqual(["chromium-mockups", "firefox", "webkit"]); + + // `chromium` (the production project) is the one the ui-critical job already + // proved in this run. Re-running it here is the waste the primary path exists + // to avoid — but only Chromium, never the cross-engine backstop. + expect([...byEngine.values()].flat()).not.toContain("chromium"); + expect([...byEngine.values()].flat()).toContain("firefox"); + expect([...byEngine.values()].flat()).toContain("webkit"); + }); + + it("uploads diagnostics under a per-engine artifact name", () => { + // A shared name makes the second failing engine fail on upload, so the + // engine that failed second loses its trace exactly when it is needed. + expect(releaseJob).toContain("release-ui-diagnostics-${{ github.run_id }}-${{ matrix.engine }}"); + }); +}); diff --git a/tests/ci-cache-safety.test.ts b/tests/ci-cache-safety.test.ts index a5ae078bc..2d8b28c90 100644 --- a/tests/ci-cache-safety.test.ts +++ b/tests/ci-cache-safety.test.ts @@ -284,8 +284,21 @@ describe("CI cache safety", () => { }); expect(releaseJob).not.toContain("path: .next/cache"); expect(releaseJob).not.toContain("run: npm run build"); - expect(releaseJob).toContain("npm run test:e2e -- --project=chromium-mockups --project=firefox --project=webkit"); expect(releaseJob).toContain("npm run test:e2e"); + + // Until 2026-09-07 this pinned the single-job command + // `npm run test:e2e -- --project=chromium-mockups --project=firefox --project=webkit`. + // That job stopped finishing — 70m23s and 70m20s on two consecutive main + // runs, both exactly on the old 70-minute cap — so the engines now run as + // sibling matrix jobs and the flags are assembled per engine in the step. + // The property this case still owns is the one it always owned: the primary + // path does not re-run production Chromium that ui-critical already proved. + // Full engine/project coverage is proven in + // tests/ci-browser-matrix-coverage.test.ts, which fails closed when a + // playwright.config.ts project is not assigned to an engine. + expect(releaseJob).toContain('chromium) PROJECTS="--project=chromium-mockups"'); + expect(releaseJob).toContain('firefox) PROJECTS="--project=firefox"'); + expect(releaseJob).toContain('webkit) PROJECTS="--project=webkit"'); }); it("scopes the main-branch release backstop to UI, performance, or lockfile risk", () => { From 3b191e4d0e0a424b84bf6d3c788040e4fb499add Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:29:16 +0000 Subject: [PATCH 07/11] feat(dsm): surface differential discriminators, make every row actionable, copy the ICD-10 code Four changes to the diagnosis page, all in the differential sidebar and header. 1. DISCRIMINATORS. 534 of the 688 differential rows the sidebar shows (78%) end in a parenthetical that is the clinical reason the differential is raised - "Social anxiety disorder (expected attacks in social situations)". Rendering the whole string on one line buried that behind the name, so the list read as labels rather than something that helps separate two candidates. The name is now the row and the reason is a second line under it. NOT sourced from cross-mode-differentials-index.json, which was the obvious candidate and is wrong for this. Its clinicalHinge is per PRESENTATION GROUP, not per differential: 201 entries share 31 distinct hinge strings, so social-anxiety-disorder carries "Abrupt peak over minutes, recurrent unexpected attacks, anticipatory anxiety or avoidance" - which describes panic disorder, the presentation, not social anxiety. Rendering that under a differential's name would state something clinically false about that diagnosis. The parenthetical is authored on the record against that exact differential, so it cannot be mismatched. Measured coverage would also have been 16% rather than 78%. 2. EVERY ROW IS ACTIONABLE. Rows that resolve to a record open it; the rest - "Medical cause (cardiac, respiratory, endocrine)" and its kind, which name a category rather than a DSM record - search for the name. Previously those rendered as inert text beside linked siblings with nothing to explain the difference, so the list looked half-broken rather than deliberate. 3. COMPARE PER ROW. Compare was one page-level button. When two candidates are in play the natural action is to compare from the row being read, so each resolving row carries a compare control to this diagnosis. 4. COPY THE ICD-10 CODE. It is the one string on the page that gets retyped into a coding field, and it was plain text in a chip sized for reading. The header takes an opt-in copyCode flag so the comparison and differential- considerations pages, which pass a summary string rather than one diagnosis's code, keep rendering text and cannot copy the wrong thing. Both new controls are min-h-tap: they are phone controls in a dense list where the neighbouring rows are other diagnoses. Measured 48x48 and 72x48 at 390px. Verified in the browser at 1280px and 390px: no console errors, zero horizontal overflow, and the copy control puts "F41.0" on the clipboard with its accessible name updating to "Copied ICD-10 code F41.0". tests/dsm-differential-parts.test.ts proves the split against the whole corpus: every differential must rebuild exactly from its parts, and any name that resolved to a diagnosis whole must still resolve after splitting, so the change cannot rewrite clinical text or cost a link. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- src/components/dsm/dsm-code-copy.tsx | 67 +++++++++++++++++ src/components/dsm/dsm-diagnosis-page.tsx | 67 +++++++++++++---- src/components/dsm/dsm-page-header.tsx | 21 +++++- src/lib/dsm.ts | 48 ++++++++++++ tests/dsm-differential-parts.test.ts | 75 +++++++++++++++++++ tests/dsm-differential-rows.dom.test.tsx | 91 +++++++++++++++++++++++ 6 files changed, 355 insertions(+), 14 deletions(-) create mode 100644 src/components/dsm/dsm-code-copy.tsx create mode 100644 tests/dsm-differential-parts.test.ts create mode 100644 tests/dsm-differential-rows.dom.test.tsx diff --git a/src/components/dsm/dsm-code-copy.tsx b/src/components/dsm/dsm-code-copy.tsx new file mode 100644 index 000000000..ad2dc4ae4 --- /dev/null +++ b/src/components/dsm/dsm-code-copy.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useRef, useState } from "react"; + +import { cn, codeText, metadataPill } from "@/components/ui-primitives"; +import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; + +/** + * The ICD-10 code chip, made copyable. + * + * The code is the one string on this page that gets retyped into a coding field + * or a discharge summary, and it was rendered as plain text, so it had to be + * selected by hand from a chip sized for reading rather than selecting. This + * keeps the chip's appearance and adds the one action it was missing. + * + * Deliberately a button rather than a click handler on the chip: it is an + * action, so it needs a role, a focusable target, and an accessible name that + * says what it does rather than just reading the code aloud. + */ + +const COPY_RESET_MS = 2000; + +export function DsmCodeCopy({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const timer = useRef(null); + + async function copy() { + try { + await copyTextToClipboard(code); + setCopied(true); + } catch { + // A failed copy must not claim success. The code stays visible and + // selectable, which is exactly the fallback that existed before. + setCopied(false); + return; + } + if (timer.current) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), COPY_RESET_MS); + } + + return ( + + ); +} diff --git a/src/components/dsm/dsm-diagnosis-page.tsx b/src/components/dsm/dsm-diagnosis-page.tsx index b114ac718..dcee4d641 100644 --- a/src/components/dsm/dsm-diagnosis-page.tsx +++ b/src/components/dsm/dsm-diagnosis-page.tsx @@ -22,6 +22,7 @@ import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in- import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; import { dsmCriteria, + dsmDifferentialParts, dsmSpecifierSplit, resolveDsmDifferential, type DsmDiagnosis, @@ -133,6 +134,7 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) { title={diagnosis.title} description="Core diagnostic criteria, specifiers, differential considerations, and documentation support in one open, scan-friendly view." code={diagnosis.icd_code} + copyCode category={diagnosis.category.label} breadcrumb={false} /> @@ -306,24 +308,63 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) {
    {sidebarDifferentials.map((differential) => { const match = resolveDsmDifferential(differential); + const { name, discriminator } = dsmDifferentialParts(differential); + + /* + * Every row is actionable, which it was not before. Rows that + * resolve to a record open it; the rest — "Medical cause + * (cardiac, respiratory, endocrine)" and its kind, which name + * a category rather than a DSM record — search for the name. + * Previously those rendered as inert text beside linked + * siblings with nothing to explain the difference, so the + * list looked half-broken rather than deliberate. + */ + const href = match + ? `/dsm/diagnoses/${match.slug}` + : `/dsm/search?q=${encodeURIComponent(name)}&run=1`; + return (
  • - {match ? ( +
    - {differential} - + + {name} + {match ? ( + + ) : ( + + )} + + {discriminator ? ( + + {discriminator} + + ) : null} - ) : ( - - {differential} - - )} + {match ? ( + + + + ) : null} +
  • ); })} diff --git a/src/components/dsm/dsm-page-header.tsx b/src/components/dsm/dsm-page-header.tsx index 3b606cb0b..bf0f34ec9 100644 --- a/src/components/dsm/dsm-page-header.tsx +++ b/src/components/dsm/dsm-page-header.tsx @@ -1,6 +1,7 @@ import { BookOpenCheck, type LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; +import { DsmCodeCopy } from "@/components/dsm/dsm-code-copy"; import { InformationPageBreadcrumbs } from "@/components/information-page-shell"; import { PageHeader } from "@/components/ui/page-header"; import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; @@ -11,6 +12,7 @@ export function DsmPageHeader({ title, description, code, + copyCode = false, category, actions, className, @@ -22,6 +24,11 @@ export function DsmPageHeader({ title: string; description?: string; code?: string; + /** + * Render the code as a copy control. Only true where the code belongs to one + * diagnosis; a page showing a summary across records must leave it as text. + */ + copyCode?: boolean; category?: string; actions?: ReactNode; className?: string; @@ -57,7 +64,19 @@ export function DsmPageHeader({ meta={ code || category ? ( <> - {code ? {code} : null} + {/* + Copyable only where a single record owns the code. The + comparison and differential-considerations pages pass a + summary string rather than one diagnosis's code, and copying + that would put the wrong thing on the clipboard. + */} + {code ? ( + copyCode ? ( + + ) : ( + {code} + ) + ) : null} {category ? {category} : null} Local clinical reference diff --git a/src/lib/dsm.ts b/src/lib/dsm.ts index 4cd3b2f5d..4ce95ea46 100644 --- a/src/lib/dsm.ts +++ b/src/lib/dsm.ts @@ -361,6 +361,54 @@ export function listDsmDiagnosisSummaries(options: { query?: string; category?: .map(dsmDiagnosisSummary); } +export type DsmDifferentialParts = { + /** The diagnosis name, with any trailing parenthetical removed. */ + name: string; + /** The authored discriminator from that parenthetical, or "" when there is none. */ + discriminator: string; +}; + +/** + * Split a differential entry into its name and the discriminator the record + * already carries for it. + * + * 534 of the 688 differential rows the sidebar shows (78%) end in a parenthetical + * that is the clinical reason the differential is being raised — "Social anxiety + * disorder (expected attacks in social situations)", "Bipolar I disorder (full + * manic episode present - reclassify)". Rendering the whole string on one line + * buried that behind the name, so the sidebar read as a list of labels rather + * than something that helps separate two candidates. + * + * DELIBERATELY NOT sourced from `cross-mode-differentials-index.json`, which was + * the obvious candidate and is wrong for this. Its `clinicalHinge` is per + * PRESENTATION GROUP, not per differential: 201 entries share just 31 distinct + * hinge strings, so `social-anxiety-disorder` carries "Abrupt peak over minutes, + * recurrent unexpected attacks, anticipatory anxiety or avoidance" — which + * describes panic disorder, the presentation, not social anxiety. Rendering that + * under a differential's name would state something clinically false about that + * diagnosis. The parenthetical here is authored on the record itself, against + * that exact differential, so it cannot be mismatched. + * + * Only a trailing parenthetical counts. An inline one is part of the name + * ("Premenstrual dysphoric disorder (PMDD)" is a name, not a discriminator) — + * those resolve to a diagnosis and are left whole by the guard below. + */ +export function dsmDifferentialParts(value: string): DsmDifferentialParts { + const trimmed = value.trim(); + const match = /^(.*?)\s*\(([^()]*)\)$/.exec(trimmed); + if (!match) return { name: trimmed, discriminator: "" }; + + const [, name, inside] = match; + const discriminator = inside.trim(); + + // An abbreviation or alternate label is part of the name, not a reason. Both + // are short and word-like; a discriminator is a clause. + const looksLikeLabel = !/\s/.test(discriminator) || /^[A-Z0-9\-/]+$/.test(discriminator); + if (!name || looksLikeLabel) return { name: trimmed, discriminator: "" }; + + return { name, discriminator }; +} + export function resolveDsmDifferential(value: string) { const title = value.replace(/\s*\([^)]*\)\s*$/, "").trim(); const normalized = normalizeSearchText(title); diff --git a/tests/dsm-differential-parts.test.ts b/tests/dsm-differential-parts.test.ts new file mode 100644 index 000000000..05592c9a5 --- /dev/null +++ b/tests/dsm-differential-parts.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { dsmDiagnoses, dsmDifferentialParts, resolveDsmDifferential } from "@/lib/dsm"; + +/* + * The discriminator shown under each differential is pasted-adjacent clinical + * text: a reader separating two candidate diagnoses acts on it. Wrong text here + * is worse than no text, so these cases pin the split against the real corpus + * rather than against invented strings. + */ + +describe("dsmDifferentialParts", () => { + it("splits the authored reason out of a trailing parenthetical", () => { + expect(dsmDifferentialParts("Social anxiety disorder (expected attacks in social situations)")).toEqual({ + name: "Social anxiety disorder", + discriminator: "expected attacks in social situations", + }); + expect(dsmDifferentialParts("Bipolar I disorder (full manic episode present - reclassify)")).toEqual({ + name: "Bipolar I disorder", + discriminator: "full manic episode present - reclassify", + }); + }); + + it("leaves a bare differential whole", () => { + expect(dsmDifferentialParts("Adjustment disorder")).toEqual({ + name: "Adjustment disorder", + discriminator: "", + }); + }); + + it("treats an abbreviation as part of the name, not a discriminator", () => { + // "Premenstrual dysphoric disorder (PMDD)" is a name. Splitting it would + // render "PMDD" as though it were the reason the differential is raised, and + // would also break the abbreviation lookup in resolveDsmDifferential. + expect(dsmDifferentialParts("Premenstrual dysphoric disorder (PMDD)")).toEqual({ + name: "Premenstrual dysphoric disorder (PMDD)", + discriminator: "", + }); + expect(dsmDifferentialParts("Attention-deficit/hyperactivity disorder (ADHD)").discriminator).toBe(""); + }); + + it("never invents, reorders, or drops words from the source string", () => { + // Every differential in the corpus must be reconstructible from its parts, + // which is what rules out a split that quietly rewrites clinical text. + for (const diagnosis of dsmDiagnoses) { + for (const differential of diagnosis.differentials) { + const { name, discriminator } = dsmDifferentialParts(differential); + const rebuilt = discriminator ? `${name} (${discriminator})` : name; + expect(rebuilt, `${diagnosis.title} -> ${differential}`).toBe(differential.trim()); + } + } + }); + + it("keeps the name resolvable to a diagnosis wherever the whole string was", () => { + // The sidebar links the NAME after this change. If splitting cost a link the + // whole string used to earn, the change would have removed navigation. + for (const diagnosis of dsmDiagnoses) { + for (const differential of diagnosis.differentials) { + if (!resolveDsmDifferential(differential)) continue; + expect( + resolveDsmDifferential(dsmDifferentialParts(differential).name), + `${diagnosis.title} -> ${differential} resolved whole but not by name`, + ).toBeDefined(); + } + } + }); + + it("covers a majority of the corpus, so the sidebar is not mostly empty subtitles", () => { + const rows = dsmDiagnoses.flatMap((diagnosis) => diagnosis.differentials.slice(0, 6)); + const withDiscriminator = rows.filter((row) => dsmDifferentialParts(row).discriminator).length; + // Measured 534/688 = 77.6% on 2026-09-07. A floor, not a target: a drop + // below this means the corpus changed shape and the feature stopped paying. + expect(withDiscriminator / rows.length).toBeGreaterThan(0.7); + }); +}); diff --git a/tests/dsm-differential-rows.dom.test.tsx b/tests/dsm-differential-rows.dom.test.tsx new file mode 100644 index 000000000..94de8cc2a --- /dev/null +++ b/tests/dsm-differential-rows.dom.test.tsx @@ -0,0 +1,91 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DsmDiagnosisPage } from "@/components/dsm/dsm-diagnosis-page"; +import { dsmDiagnoses, dsmDifferentialParts, resolveDsmDifferential } from "@/lib/dsm"; + +// This page pulls in the cross-mode links section, which reads auth session +// state it has no provider for under jsdom. Same stub the shared nav contract +// test uses. +vi.mock("@/components/clinical-dashboard/cross-mode-links", () => ({ + CrossModeLinksSection: () => null, +})); + +// The nav header's back control calls useRouter. Same stub as +// tests/dsm-comparison-page.dom.test.tsx. +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), + usePathname: () => "/dsm/diagnoses/panic-disorder", + useSearchParams: () => new URLSearchParams(), +})); + +afterEach(cleanup); + +const panic = dsmDiagnoses.find((diagnosis) => diagnosis.slug === "panic-disorder")!; + +function sidebar() { + return within(screen.getByLabelText("Diagnosis reference summary")); +} + +describe("DSM differential rows", () => { + it("shows the authored discriminator as its own line under the name", () => { + render(); + const list = sidebar(); + + // Before this the whole string rendered on one line, so the reason a + // differential was raised sat behind the name it belonged to. + expect(list.getByText("Social anxiety disorder")).toBeTruthy(); + expect(list.getByText("expected attacks in social situations")).toBeTruthy(); + expect(list.queryByText("Social anxiety disorder (expected attacks in social situations)")).toBeNull(); + }); + + it("gives every row somewhere to go, including rows with no DSM record", () => { + render(); + const list = sidebar(); + + // "Medical cause (cardiac, respiratory, endocrine)" names a category, not a + // record, so it resolved to nothing and used to render as inert text beside + // linked siblings with nothing to explain the difference. + const unresolved = panic.differentials.slice(0, 6).find((row) => !resolveDsmDifferential(row)); + expect(unresolved, "fixture no longer contains an unresolved differential").toBeDefined(); + + const { name } = dsmDifferentialParts(unresolved!); + const link = list.getByRole("link", { name: new RegExp(name, "i") }); + expect(link.getAttribute("href")).toBe(`/dsm/search?q=${encodeURIComponent(name)}&run=1`); + }); + + it("offers compare against the diagnosis being read, on each row that resolves", () => { + render(); + const list = sidebar(); + + const resolved = panic.differentials.slice(0, 6).find((row) => resolveDsmDifferential(row))!; + const match = resolveDsmDifferential(resolved)!; + const { name } = dsmDifferentialParts(resolved); + + const compare = list.getByRole("link", { name: `Compare ${panic.title} with ${name}` }); + // Both ids, in the order the reader is holding them: the page they are on, + // then the candidate. A compare link carrying only one id would drop the + // comparison the row exists to offer. + expect(compare.getAttribute("href")).toBe( + `/dsm/compare?ids=${encodeURIComponent(panic.slug)},${encodeURIComponent(match.slug)}`, + ); + }); + + it("offers no compare on a row with nothing to compare against", () => { + render(); + const list = sidebar(); + + const unresolved = panic.differentials.slice(0, 6).find((row) => !resolveDsmDifferential(row))!; + const { name } = dsmDifferentialParts(unresolved); + expect(list.queryByRole("link", { name: `Compare ${panic.title} with ${name}` })).toBeNull(); + }); + + it("renders the ICD-10 code as a copy control", () => { + render(); + const button = screen.getByTestId("dsm-icd-copy"); + expect(button.tagName).toBe("BUTTON"); + expect(button.getAttribute("aria-label")).toBe(`Copy ICD-10 code ${panic.icd_code}`); + // A control, not a label: it has to be pressable on a phone. + expect(button.className).toContain("min-h-tap"); + }); +}); From 3ee370a6be15a5da014146d083be9936000a68ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:33:22 +0000 Subject: [PATCH 08/11] fix(dsm): keep the tap area off the chip recipe on the ICD copy control check:design-system-contract rejected stacking min-h-tap onto metadataPill: "Chip/metadata density recipes have competing text or height utilities". That is correct - two recipes fighting over one element's height is how chip sizing drifts across the app. The button now owns the 48px tap area and the chip recipe stays on an inner span, so both keep their own job. Hover moves to group-hover so the visual still responds to the whole control. Re-measured at 390px: 72x48 tap target, clipboard receives "F41.0", zero horizontal overflow, no console errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- src/components/dsm/dsm-code-copy.tsx | 32 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/components/dsm/dsm-code-copy.tsx b/src/components/dsm/dsm-code-copy.tsx index ad2dc4ae4..315bc0bea 100644 --- a/src/components/dsm/dsm-code-copy.tsx +++ b/src/components/dsm/dsm-code-copy.tsx @@ -45,20 +45,26 @@ export function DsmCodeCopy({ code }: { code: string }) { onClick={() => void copy()} aria-label={copied ? `Copied ICD-10 code ${code}` : `Copy ICD-10 code ${code}`} data-testid="dsm-icd-copy" - className={cn( - metadataPill, - codeText, - // `min-h-tap` because this is now a control, not a label. The chip was - // sized for reading; a thing you press on a phone has to be 48px. - "inline-flex min-h-tap items-center gap-1.5 transition-colors hover:text-[color:var(--clinical-accent)]", - )} + // The TAP AREA lives here and the chip recipe stays on the span below. + // Stacking `min-h-tap` onto `metadataPill` competes with the chip's own + // height utility, which `check:design-system-contract` rejects — correctly, + // since two recipes fighting over one element is how chip sizing drifts. + className="group inline-flex min-h-tap items-center" > - {code} - {copied ? ( - - ) : ( - - )} + + {code} + {copied ? ( + + ) : ( + + )} + {copied ? "Code copied to the clipboard." : ""} From f7477e47d0ac41d65afb90b08fc13f82703f3f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:55:46 +0000 Subject: [PATCH 09/11] mockup(dsm): design scratch for saving a DSM diagnosis to favourites Saving a diagnosis is the one page-level capability the diagnosis-page review found that cannot be built without a schema change. The content type is constrained in two places at once - favouriteContentTypeSchema and the user_favourites_content_type_check constraint - and adding a value reaches the live clinical database the moment the migration merges, with no deploy step in between. That is worth deciding on a page before it is decided in a migration. The mockup puts three decisions in front of the reader rather than in a doc beside the pictures: 1. Where the save control lives: page header versus the ellipsis menu, with the recommendation and its reason stated inline. 2. What metadata a saved diagnosis carries, shown beside the four types that already exist so the row can be compared rather than imagined. Every other favourite type shows something source-backed; a diagnosis has no source, so code + category + criteria count is proposed as the equivalent. 3. What it costs to build, including the irreversible step called out explicitly. There is a precedent to copy exactly, and the mockup says so: PR 20260814150000 added 'therapy' by dropping and re-adding the check constraint, and 20260814151000 validated it as a separate migration. Same two-step. NOTHING IS WIRED. Every control is local state over fixed data, so the page renders with no account, no database and no network. Built on the project's design tokens rather than Tailwind palette classes. The first draft used bg-sky-700 / bg-slate-* and rendered invisible: this project's @theme replaces the default colour palette, so those classes generate nothing and the badge computed to rgba(0,0,0,0). Caught by reading the rendered page, not by typecheck or lint, neither of which can see a colour that does not exist. Registered in mockups/README.md, which check:mockups gates on, and the route map regenerated. check:mockups, sitemap:check, knip, lint and typecheck all pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- docs/site-map.md | 1 + mockups/README.md | 7 +- src/app/mockups/dsm-favourites/page.tsx | 5 + .../dsm-favourites-mockups-page.tsx | 247 ++++++++++++++++++ 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 src/app/mockups/dsm-favourites/page.tsx create mode 100644 src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 8d305892b..34df1cdd7 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1241,6 +1241,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/document-search/source-overlays` - Route discovered from app directory Source: `src/app/mockups/document-search/source-overlays/page.tsx`. - `/mockups/document-search/source/evidence` - Route discovered from app directory Source: `src/app/mockups/document-search/source/evidence/page.tsx`. - `/mockups/document-top-navigation` - Route discovered from app directory Source: `src/app/mockups/document-top-navigation/page.tsx`. +- `/mockups/dsm-favourites` - Route discovered from app directory Source: `src/app/mockups/dsm-favourites/page.tsx`. - `/mockups/factsheets-compact-view` - Route discovered from app directory Source: `src/app/mockups/factsheets-compact-view/page.tsx`. - `/mockups/factsheets-topics-phone` - Route discovered from app directory Source: `src/app/mockups/factsheets-topics-phone/page.tsx`. - `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. diff --git a/mockups/README.md b/mockups/README.md index 28cd6cb83..be68cf66f 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -49,9 +49,10 @@ picked — kept as-is rather than guessed at. ### Favourites page -| Route | Status | -| ---------------------------- | ---------------------------------------------------- | -| `favourites-phone-perfected` | Chosen design (2026-08-27) — see the write-up above. | +| Route | Status | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `favourites-phone-perfected` | Chosen design (2026-08-27) — see the write-up above. | +| `dsm-favourites` | Active study (2026-09-07) — save a DSM diagnosis to favourites. Open decisions only; nothing wired. Blocks on a check-constraint migration. | Six earlier studies (`favourites-command-console`, `favourites-command-desk`, `favourites-library-view`, `favourites-review-console`, `favourites-set-board`, diff --git a/src/app/mockups/dsm-favourites/page.tsx b/src/app/mockups/dsm-favourites/page.tsx new file mode 100644 index 000000000..f28a409b5 --- /dev/null +++ b/src/app/mockups/dsm-favourites/page.tsx @@ -0,0 +1,5 @@ +import { DsmFavouritesMockupsPage } from "@/components/dsm-favourites-mockups/dsm-favourites-mockups-page"; + +export default function DsmFavouritesMockupRoute() { + return ; +} diff --git a/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx b/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx new file mode 100644 index 000000000..ca4711f5f --- /dev/null +++ b/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { BookOpenCheck, Check, ChevronRight, Pin, Star, Tag } from "lucide-react"; +import { useState } from "react"; + +/** + * Design scratch for "save a DSM diagnosis to favourites". + * + * This is the ONE page-level capability the diagnosis-page review found that + * cannot be built without a schema change: `favouriteContentTypeSchema` and the + * `user_favourites_content_type_check` constraint both allow only + * service | form | differential | therapy. Adding `dsm` reaches the live clinical + * database on merge, so the shape wants deciding on a page before it is decided + * in a migration. + * + * There is a precedent to copy exactly: `20260814150000_add_therapy_favourites.sql` + * dropped and re-added the check constraint with `therapy`, and + * `20260814151000_validate_therapy_favourites_content_type.sql` validated it as a + * separate migration. Same two-step here. + * + * NOTHING BELOW IS WIRED. Every control is local state over fixed data, so the + * page can be read without a database, an account, or a decision having been + * made. The open questions are stated on the page itself rather than in a doc + * nobody opens next to the pictures. + */ + +type SaveState = "idle" | "saved"; + +const DIAGNOSIS = { + title: "Panic disorder", + icd: "F41.0", + category: "Anxiety disorders", + criteria: "4 criteria, A-D", +}; + +const SET_OPTIONS = ["Unsorted", "Clinical review", "Ward round", "On call", "Teaching", "Reference"] as const; + +/** What a saved DSM favourite would look like beside the types that already exist. */ +const LIBRARY_ROWS: ReadonlyArray<{ + type: string; + title: string; + meta: string; + set: string; + isNew?: boolean; +}> = [ + { + type: "DSM diagnosis", + title: "Panic disorder", + meta: "F41.0 · Anxiety disorders · 4 criteria", + set: "Clinical review", + isNew: true, + }, + { type: "Therapy", title: "CBT for panic", meta: "Structured protocol · 12 sessions", set: "Clinical review" }, + { type: "Differential", title: "Acute chest pain", meta: "Presentation · 9 candidates", set: "Ward round" }, + { type: "Form", title: "Form 1A — Referral", meta: "Mental Health Act 2014", set: "On call" }, +] as const; + +function Panel({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
    +

    + + {n} + + {title} +

    + {children} +
    + ); +} + +function Question({ children }: { children: React.ReactNode }) { + return ( +

    + Decision: + {children} +

    + ); +} + +export function DsmFavouritesMockupsPage() { + const [headerSave, setHeaderSave] = useState("idle"); + const [chosenSet, setChosenSet] = useState<(typeof SET_OPTIONS)[number]>("Unsorted"); + const [pinned, setPinned] = useState(false); + + return ( +
    +
    +

    + Mockup · not wired +

    +

    + Save a DSM diagnosis to favourites +

    +

    + Favourites currently accept services, forms, differentials and therapies. A diagnosis cannot be saved, because + the content type is constrained in two places at once: a Zod enum and a Postgres check constraint. Adding one + reaches the live clinical database the moment the migration merges, so the three decisions below are worth + settling here first. +

    +
    + + +
    +
    + + {DIAGNOSIS.title} + + {DIAGNOSIS.icd} + + + {DIAGNOSIS.category} + + + +
    + + {headerSave === "saved" ? ( +
    + + Add to set +
    + {SET_OPTIONS.map((option) => ( + + ))} +
    + +
    + ) : null} +
    + + + Save sits in the page header here, beside the code. The alternative is the ellipsis menu, where Compare used + to live. Header costs a permanent control on every diagnosis page; the menu costs a tap and is easy to miss. + My recommendation is the header, because saving is a one-tap action you do while reading, and the menu is + where Compare was when the review found nobody used it. + +
    + + +
      + {LIBRARY_ROWS.map((row) => ( +
    • + + {row.type} + + + {row.title} + {row.meta} + + {row.set} + +
    • + ))} +
    + + + The metadata line reads code · category · criteria count. Every other favourite type shows something + source-backed there. A diagnosis has no source to cite, so this is the closest equivalent — it is what tells + two saved anxiety disorders apart in a list. Confirm that is the right three facts, or name different ones. + +
    + + +
      +
    1. + 1. Two-step migration. Drop and + re-add + + user_favourites_content_type_check + + with dsm{" "} + added, then a separate validate migration. This is exactly the therapy precedent from 2026-08-14, which is + the reason this is a known-safe shape rather than a new one. +
    2. +
    3. + 2. Widen the enum in the favourites + contract, and add a diagnosis branch to the reference check so a saved favourite must point at a real + record. +
    4. +
    5. + 3. Wire the control and give the + library its label and icon. +
    6. +
    + +

    + Irreversible step: + merging the migration applies it to the live clinical database within seconds, with no deploy step in between. + It is additive and widens what is allowed rather than narrowing it, so nothing existing breaks — but it merges + inside an approved window, never on auto-merge. +

    +
    + +
    + Static mockup. No account, no database, no network. The controls above change local state only, so nothing here + can save anything. +
    +
    + ); +} From 11241fb63de932e637e09653fc47828fb862fcb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:01:01 +0000 Subject: [PATCH 10/11] fix(mockup): use the named type-scale step instead of an arbitrary font size check:type-scale --strict failed on the mockup: three text-[11px] utilities bypass the scale. Mockups are exempt from several design rules - hardcoded hex, the z-index ladder, lucide icon aria, and check:design-system-contract - but the type scale is not one of them, and the exemption list is the kind of thing that is easy to assume rather than check. text-2xs computes to exactly 11px, so the rendering is byte-identical and this is purely the named step replacing the arbitrary one. My process error, not a surprise from the repo: after adding the mockup I ran a targeted subset (lint, knip, check:mockups, sitemap:check, design-system contract) instead of the gate that covers the change, so a static check that had passed on the previous commit was never re-run on this one. The full verify:pr-local run follows this commit rather than preceding the push. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- .../dsm-favourites-mockups-page.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx b/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx index ca4711f5f..a63bde417 100644 --- a/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx +++ b/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx @@ -59,7 +59,7 @@ function Panel({ n, title, children }: { n: number; title: string; children: Rea return (

    - + {n} {title} @@ -86,9 +86,7 @@ export function DsmFavouritesMockupsPage() { return (
    -

    - Mockup · not wired -

    +

    Mockup · not wired

    Save a DSM diagnosis to favourites

    @@ -182,7 +180,7 @@ export function DsmFavouritesMockupsPage() { className={`flex items-center gap-3 px-3 py-3 ${row.isNew ? "bg-[color:var(--clinical-accent-soft)]" : "bg-[color:var(--surface)]"}`} > Date: Mon, 7 Sep 2026 12:03:47 +0000 Subject: [PATCH 11/11] chore(mockup): record the new route in the repo-awareness snapshot check:repo-awareness-snapshot failed: adding src/app/mockups/dsm-favourites left the committed snapshot behind the repository's route list. Regenerated with npm run snapshot:repo-awareness. The diff is additive and contains only the new route entry, nothing else. Second thing the full gate caught that a targeted subset had not. Adding a route touches more generated records than the ones I thought to check by hand - sitemap and the mockup index were regenerated, this one was not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017fBy41i2ynVpA51b5CuYSD --- data/repo-awareness-snapshot.json | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index bf47a706d..af85eabf9 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -909,6 +909,11 @@ "file": "src/app/mockups/document-search/page.tsx", "area": "mockup" }, + { + "path": "/mockups/dsm-favourites", + "file": "src/app/mockups/dsm-favourites/page.tsx", + "area": "mockup" + }, { "path": "/mockups/therapy-navigation-rail", "file": "src/app/mockups/therapy-navigation-rail/page.tsx", @@ -6424,6 +6429,14 @@ "outcome": "CHANGES REQUESTED / findings. No P0. Two confirmed P2 UX defects: (1) mobile presentation Compare tab is a self-link that drops `ids`/`q` and resets selection (1→default 4) + clears Query chip; (2) document “Browse by tag” / `searchByTag` navigates to `/?mode=documents&q=…` without `run=1`, so results do not run. P3: comparison disabled dropdown/filter controls lack `aria-describedby` placeholder contract; bare `/documents` 404 (no inbound links; `/documents/search` is canonical); phone mode-switcher truncates “Differenti…”. Deduped: `/tools` vs `/?mode=tools` remains #007; coming-soon density/favourites/forms remain #010. Phone docks flush `bottom:0` on sampled result routes; no document horizontal overflow at 390/768/1280 across 30 routes.", "checks": "`npm run workflow:design-sweep -- --write-evidence`; `npm run ensure` → http://localhost:4461 identity Clinical KB; Playwright HTTP+overflow matrix 30/30 no overflow; live Compare/tag proofs + phone route matrix; `npm run test:e2e:accessibility` 12/12; screenshots under `/opt/cursor/artifacts/screenshots/design-review-2026-07-24/`. No OpenAI/Supabase/GitHub/hosted CI/provider mutations. Added project subagent `.cursor/agents/design-review.md`." }, + { + "date": "2026-09-07", + "ref": "PR-2601", + "head": "01cfdbd9f314291640e5d39bc698a7fcc418b3b4", + "scope": "PR CI and review repair", + "outcome": "Fixed three unresolved governance findings and the deterministic forms sorting CI failure; focused calculator checks, lint and typecheck passed.", + "checks": "vitest calculators-governance-hardening (8); check:calculator-content; lint; typecheck; verify:pr-local offline stages through unit suite start" + }, { "date": "2026-07-14", "ref": "codex/global-answer-reliability", @@ -17440,6 +17453,14 @@ "outcome": "FIXED. Supersedes prior reviews after merging current-main PR #1469. No remaining P0-P2 findings; #107 is archived with executing jsdom state-matrix coverage, and the branch's existing changes remain intact.", "checks": "focused current-main state-matrix suite 2 files / 10 tests PASS; outstanding ledger 146 rows / 49 open / 97 archived PASS; branch-review ledger PASS; prior combined-tree verify:cheap 32 gates PASS" }, + { + "date": "2026-09-07", + "ref": "PR-2693", + "head": "85aad321b9a67eefa4dac2acb22552b8086df9c5", + "scope": "PR CI and review repair", + "outcome": "Verified complete 24-request reconciliation and fixed deterministic forms sorting CI expectation.", + "checks": "check:outstanding-issues; check:ledger-write-discipline; installed-lock parity; merge-tree clean" + }, { "date": "2026-07-28", "ref": "PR #1295 / `fix/audit-remediation-from-main`", @@ -21408,6 +21429,14 @@ "outcome": "Redundant: no patch-unique non-merge commits remain against `origin/main`; eligible for deletion when unreferenced.", "checks": "`git log --right-only --cherry-pick --no-merges origin/main...claude/mobile-search-bar-fix` returned empty." }, + { + "date": "2026-09-05", + "ref": "codex/calculators-governance-hardening (PR #2601)", + "head": "b755976a79bb8e0b203fd0ede882685f6804f1eb", + "scope": "Run PR sweep: CI fix + threads + drift", + "outcome": "before: PR required green but BEHIND main, 1 unresolved P2 thread (governance checker wiring test never exercised failure path). after: merged origin/main (clean, no conflicts), added test that runs the real checker script against corrupted fixture data and asserts nonzero exit + diagnostic, thread replied and resolved.", + "checks": "npx vitest run tests/calculators-governance-hardening.test.ts (6 passed); npm run typecheck (clean, recorded pass); npx prettier --write (unchanged); git merge-tree confirmed clean before merging origin/main. No provider-backed checks run." + }, { "date": "2026-08-30", "ref": "codex/smart-natural-search-current-main",