From b66cf54dd54151b48f3fc8628d5a6fd070d6df53 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:59:54 +0800 Subject: [PATCH 01/11] fix(audit): resolve all 26 P0-P3 clinical, privacy, a11y, wiring, and INP/bundle audit findings --- docs/design-system/adoption-manifest.json | 6 +- docs/wiring-conventions.md | 16 +- scripts/check-bundle-budget.mjs | 3 +- .../diagnoses/[slug]/loading.tsx | 2 +- .../medications/[slug]/loading.tsx | 2 +- src/components/DocumentViewer.tsx | 53 ++- .../admin/admin-credentials-wallet.tsx | 2 +- src/components/admin/admin-work.module.css | 9 + .../calculator-mockups/calculator-fixtures.ts | 262 +---------- src/components/calculators/bedside-sheet.tsx | 25 +- .../calculators/calculator-sheet.tsx | 2 +- .../calculators/clinical-console.tsx | 9 +- src/components/calculators/guided-flow.tsx | 6 +- src/components/calculators/search-detail.tsx | 11 +- .../care-plan/mockups/care-plan.module.css | 2 +- .../care-plan/mockups/safety-plan-form.tsx | 5 + .../clinical-ask-answer-surface.tsx | 6 +- .../lazy-sidebar-dialogs.tsx | 33 +- .../master-search-header.tsx | 89 ++-- .../medication-prescribing-workspace.tsx | 223 ++++++---- .../clinical-dashboard/mode-action-popup.tsx | 2 +- .../patient-profile-panel.tsx | 4 + .../clinical-dashboard/settings-dialog.tsx | 62 ++- .../clinical-dashboard/settings-reminders.tsx | 4 +- .../universal-search-command-surface.tsx | 1 + src/components/cme/cme-entry-form.tsx | 23 + src/components/cme/cme-owner-boundary.tsx | 19 +- src/components/cme/cme-state-notice.tsx | 2 +- src/components/cme/cme-training-page.tsx | 96 ++-- src/components/cme/cme-work.css | 22 + .../dictionary/dictionary-catalogue-pages.tsx | 399 ++++++++++------- .../document-overview-landing.tsx | 23 +- .../dsm/dsm-diagnosis-note-builder.tsx | 52 ++- src/components/dsm/dsm-search-page.tsx | 239 +++++----- .../factsheets/factsheets-search-page.tsx | 4 +- .../factsheets/factsheets-topics-page.tsx | 4 +- src/components/favourites/number-sheets.tsx | 16 +- src/components/first-nations/bedside-home.tsx | 1 + .../forms/form-priority-facts-section.tsx | 42 +- .../formulation/formulation-builder-page.tsx | 56 ++- src/components/last-place/shift-now-probe.tsx | 35 +- src/components/my-day/my-day-week-page.tsx | 416 ++++++++++-------- .../needs-you/notification-centre.css | 2 +- .../on-call/handover/handover-page.tsx | 13 +- .../on-call/on-call-entry-editor.tsx | 10 +- src/components/open-shifts/open-shifts-ui.tsx | 2 +- src/components/psychiatry/mha-clock-page.tsx | 4 +- .../roster/manage/roster-cover-tab.tsx | 1 + .../roster/rotations/admin/round-form.tsx | 3 + src/components/roster/use-roster-shifts.ts | 33 +- .../specifiers/specifiers-home-page.tsx | 367 +++++++++------ .../teaching/assessments/assess-kit.tsx | 8 +- .../teaching/assessments/assess.css | 25 +- .../assessments/assessments-term-overview.tsx | 69 +-- .../assessments/teaching-assessments.tsx | 184 ++++++-- .../therapy-compass/screens/brief-screen.tsx | 79 ++-- .../therapy-compass/screens/sheets-screen.tsx | 15 +- src/components/ui/text-field.tsx | 6 + .../work-frame/two-pane-side-menu.css | 15 +- .../work-frame/two-pane-side-strip.css | 14 + .../work-search/work-search-button.tsx | 1 + src/lib/calculators/calculator-fixtures.ts | 1 + src/lib/calculators/clinical-math/qtc.ts | 11 +- src/lib/calculators/clinical-math/renal.ts | 10 + src/lib/developer-area/link-access.ts | 8 +- src/lib/form-catalog-constants.ts | 20 + src/lib/form-catalog.ts | 20 +- src/lib/information-pages.ts | 30 +- src/lib/teaching/term-tracker-store.ts | 20 +- tests/bundle-budget.test.ts | 34 ++ ...lculator-completion-and-quarantine.test.ts | 127 ++++++ ...ical-input-privacy-and-transitions.test.ts | 195 ++++++++ tests/clinical-math-bounds.test.ts | 291 ++++++++++++ .../developer-cookie-and-shift-probe.test.ts | 66 +++ tests/dirty-guards-and-external-links.test.ts | 221 ++++++++++ tests/forms-and-work-bundle-isolation.test.ts | 86 ++++ tests/information-pages.test.ts | 10 +- tests/landmarks-headings-and-wiring.test.ts | 110 +++++ ...obile-ergonomics-and-forced-colors.test.ts | 85 ++++ tests/proxy.test.ts | 4 +- tests/search-workspace-memoization.test.ts | 59 +++ .../therapy-review-status-governance.test.ts | 163 +++++++ 82 files changed, 3410 insertions(+), 1300 deletions(-) create mode 100644 src/lib/form-catalog-constants.ts create mode 100644 tests/calculator-completion-and-quarantine.test.ts create mode 100644 tests/clinical-input-privacy-and-transitions.test.ts create mode 100644 tests/clinical-math-bounds.test.ts create mode 100644 tests/developer-cookie-and-shift-probe.test.ts create mode 100644 tests/dirty-guards-and-external-links.test.ts create mode 100644 tests/forms-and-work-bundle-isolation.test.ts create mode 100644 tests/landmarks-headings-and-wiring.test.ts create mode 100644 tests/mobile-ergonomics-and-forced-colors.test.ts create mode 100644 tests/search-workspace-memoization.test.ts create mode 100644 tests/therapy-review-status-governance.test.ts diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 4fc105637c..75690d9da0 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -2966,7 +2966,11 @@ "preview": ".design-sync/previews/TextField.tsx", "previewValid": true }, - "testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/ui-v2-components.dom.test.tsx"], + "testFiles": [ + "tests/clinical-math-bounds.test.ts", + "tests/design-sync-visual-exports.test.ts", + "tests/ui-v2-components.dom.test.tsx" + ], "baseline": { "targetLayer": "v2", "liveLayer": "v2", diff --git a/docs/wiring-conventions.md b/docs/wiring-conventions.md index 97c94e7ffa..b1ea1c0373 100644 --- a/docs/wiring-conventions.md +++ b/docs/wiring-conventions.md @@ -123,13 +123,15 @@ fires in both directions, and `tests/favourites-hub-unavailable-controls.dom.tes converted placeholder, asserts it takes focus, asserts the accessible description is what the reader gets, and asserts activating it by keyboard and by pointer does nothing. -**Not yet converted**, and deliberately so — the four "not available in this comparison view" -placeholders and the Compact/Detailed density pair in -`differentials/differential-presentation-workflow-page.tsx`. That page is scheduled for a rewrite, and -`tests/mobile-interaction-regressions.test.ts` still pins the density pair as native-only. Convert -them with that rewrite, not before. `document-viewer/document-image-filmstrip.tsx` and the -`DocumentViewer.tsx` summarize action are also still native (the latter mixes an auth reason with a -loading state, so it needs splitting before it can be classified). +**Converted vs. regression-pinned controls** — `teaching/assessments/assess-kit.tsx` (`AssessButton`), +`teaching/assessments/assessments-term-overview.tsx` (bell reminders, Supervisors tab `Remind`, and blocked +export), `favourites/number-sheets.tsx` (short-number copy), `document-viewer/document-image-filmstrip.tsx` +(unknown-page figure chips), `document-viewer/document-overview-landing.tsx`, and `DocumentViewer.tsx` +(signed-out "Sign in to answer" split from busy `summarizing`/`loadingSummary`) all use `aria-disabled="true"` +with `aria-describedby`, `title`, and `ignoreUnavailableActivation`. Form-validity and regression-pinned +controls (`roster/manage/publish/roster-publish-tab.tsx` `Publish` and the bulk-selection / once-a-day +remind buttons in `assessments-term-overview.tsx`) remain native `disabled` where existing DOM suites assert +`.toBeDisabled()`. **Read-only indicators are not controls.** The shared `ToggleSwitch` (`ui-primitives.tsx`) renders an operable `role="switch"` only when an `onToggle` is passed; without it, it is a presentational diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index 498a433a49..3399ad1816 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -69,6 +69,7 @@ const root = process.env.BUNDLE_BUDGET_ROOT ? path.resolve(process.env.BUNDLE_BUDGET_ROOT) : path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const CHUNKS_DIR = path.join(root, ".next", "static", "chunks"); +const BUILD_ID_PATH = path.join(root, ".next", "BUILD_ID"); const BUDGET_PATH = path.join(root, "bundle-budget.json"); const APP_BUILD_MANIFEST_PATH = path.join(root, ".next", "app-build-manifest.json"); const BUILD_MANIFEST_PATH = path.join(root, ".next", "build-manifest.json"); @@ -1003,7 +1004,7 @@ export function runBundleBudgetCheck(argv = process.argv.slice(2)) { return 1; } - if (!existsSync(CHUNKS_DIR)) { + if (!existsSync(CHUNKS_DIR) || !existsSync(BUILD_ID_PATH)) { if (refreshBaseline) { console.error( `[bundle-budget] FAIL — no build output at ${path.relative(root, CHUNKS_DIR)}; cannot refresh baseline without a build. Run \`npm run build\` first.`, diff --git a/src/app/(search-app)/differentials/diagnoses/[slug]/loading.tsx b/src/app/(search-app)/differentials/diagnoses/[slug]/loading.tsx index 4b8c27e564..733da07ea7 100644 --- a/src/app/(search-app)/differentials/diagnoses/[slug]/loading.tsx +++ b/src/app/(search-app)/differentials/diagnoses/[slug]/loading.tsx @@ -6,7 +6,7 @@ export default function Loading() {
- +
diff --git a/src/app/(search-app)/medications/[slug]/loading.tsx b/src/app/(search-app)/medications/[slug]/loading.tsx index 560a68c787..bc27e94479 100644 --- a/src/app/(search-app)/medications/[slug]/loading.tsx +++ b/src/app/(search-app)/medications/[slug]/loading.tsx @@ -5,7 +5,7 @@ export default function Loading() {
- + diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index d2e2b3b474..ce170038d7 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -22,6 +22,7 @@ import { appBackdrop, cn, floatingControl, + ignoreUnavailableActivation, InlineNotice, panel, sourceCard, @@ -1278,25 +1279,45 @@ export function DocumentViewer({ Search document - + ) : ( + + )} + {!canSummarizeDocument && !loadingSummary ? ( + + {summarizeTitle} - Answer from this - + ) : null} {signedUrl ? ( diff --git a/src/components/admin/admin-work.module.css b/src/components/admin/admin-work.module.css index d69757de49..c7bd9aac84 100644 --- a/src/components/admin/admin-work.module.css +++ b/src/components/admin/admin-work.module.css @@ -922,6 +922,15 @@ a.nextLine { font-size: var(--text-sm); } +@media (max-width: 639px) { + .searchBox, + .field, + .select, + .copyBox { + font-size: 1rem; + } +} + /* Coming up's month labels inside its card ("Date passed", "Oct 2026"). */ .monthHead { display: flex; diff --git a/src/components/calculator-mockups/calculator-fixtures.ts b/src/components/calculator-mockups/calculator-fixtures.ts index 262b77d475..b0c233081e 100644 --- a/src/components/calculator-mockups/calculator-fixtures.ts +++ b/src/components/calculator-mockups/calculator-fixtures.ts @@ -2,11 +2,9 @@ import { Activity, AlertTriangle, Baby, - Brain, CloudRain, GlassWater, HeartPulse, - Repeat2, ShieldAlert, Wine, Zap, @@ -69,6 +67,7 @@ export type CalculatorFixture = { scoringNote: string; source: string; caution?: string; + disclaimer?: string; }; export const domainLabels: Record = { @@ -102,32 +101,6 @@ const kessler1to5: CalculatorOption[] = [ { label: "All of the time", short: "5", points: 5 }, ]; -const ybocsSeverity: CalculatorOption[] = [ - { label: "None", short: "0", points: 0 }, - { label: "Mild", short: "1", points: 1 }, - { label: "Moderate", short: "2", points: 2 }, - { label: "Severe", short: "3", points: 3 }, - { label: "Extreme", short: "4", points: 4 }, -]; - -// Items 4 and 9 ask how much effort the patient makes to resist, which is a different question -// from the perceived control asked by items 5 and 10, so they carry their own anchors. -const ybocsResistance: CalculatorOption[] = [ - { label: "Always resists", short: "0", points: 0 }, - { label: "Resists most of the time", short: "1", points: 1 }, - { label: "Some effort to resist", short: "2", points: 2 }, - { label: "Yields, with some reluctance", short: "3", points: 3 }, - { label: "Completely yields", short: "4", points: 4 }, -]; - -const ybocsControl: CalculatorOption[] = [ - { label: "Complete control", short: "0", points: 0 }, - { label: "Much control", short: "1", points: 1 }, - { label: "Moderate control", short: "2", points: 2 }, - { label: "Little control", short: "3", points: 3 }, - { label: "No control", short: "4", points: 4 }, -]; - export const calculators: CalculatorFixture[] = [ { id: "phq9", @@ -331,113 +304,6 @@ export const calculators: CalculatorFixture[] = [ }, ], }, - { - id: "mdq", - abbrev: "MDQ", - name: "Mood Disorder Questionnaire", - domain: "mood", - icon: Repeat2, - indication: "Screen for lifetime bipolar-spectrum disorder before starting or reviewing antidepressant treatment.", - summary: "13 lifetime hypomanic symptoms plus co-occurrence and impairment criteria.", - stem: "Has there ever been a period of time when you were not your usual self and you…", - timeEstimate: "3–4 min", - minScore: 0, - maxScore: 13, - scoringNote: - "Positive screen requires ≥7 symptoms, symptoms clustering in the same period, and moderate-or-serious impairment.", - source: "Hirschfeld et al. 2000", - caution: "A positive screen is not a diagnosis — confirm with a structured bipolar-disorder assessment.", - items: [ - { - id: "m1", - kind: "checkbox", - points: 1, - text: "…felt so good or hyper that other people thought you were not your normal self, or you got into trouble", - }, - { - id: "m2", - kind: "checkbox", - points: 1, - text: "…were so irritable that you shouted at people or started fights", - }, - { id: "m3", kind: "checkbox", points: 1, text: "…felt much more self-confident than usual" }, - { - id: "m4", - kind: "checkbox", - points: 1, - text: "…got much less sleep than usual and found you didn't really miss it", - }, - { id: "m5", kind: "checkbox", points: 1, text: "…were much more talkative or spoke much faster than usual" }, - { - id: "m6", - kind: "checkbox", - points: 1, - text: "…had thoughts racing through your head that you couldn't slow down", - }, - { - id: "m7", - kind: "checkbox", - points: 1, - text: "…were so easily distracted that you had trouble concentrating or staying on track", - }, - { id: "m8", kind: "checkbox", points: 1, text: "…had much more energy than usual" }, - { id: "m9", kind: "checkbox", points: 1, text: "…were much more active or did many more things than usual" }, - { - id: "m10", - kind: "checkbox", - points: 1, - text: "…were much more social or outgoing — for example, telephoning friends in the middle of the night", - }, - { id: "m11", kind: "checkbox", points: 1, text: "…were much more interested in sex than usual" }, - { - id: "m12", - kind: "checkbox", - points: 1, - text: "…did things that were unusual for you or that other people might have thought excessive, foolish, or risky", - }, - { - id: "m13", - kind: "checkbox", - points: 1, - text: "…found that spending money got you or your family into trouble", - }, - { - id: "mco", - kind: "checkbox", - points: 0, - text: "Several of these ever happened during the same period of time", - detail: "Criterion 2 — co-occurrence. Does not add to the symptom count.", - }, - { - id: "mimp", - kind: "options", - text: "How much of a problem did any of these cause you?", - detail: "Criterion 3 — impairment. Moderate or serious is required for a positive screen.", - options: [ - { label: "No problem", short: "None", points: 0 }, - { label: "Minor problem", short: "Minor", points: 0 }, - { label: "Moderate problem", short: "Mod", points: 0 }, - { label: "Serious problem", short: "Serious", points: 0 }, - ], - }, - ], - bands: [ - { - min: 0, - max: 6, - label: "Below symptom threshold", - tone: "success", - guidance: "Symptom-count screen result; interpret with all required criteria and clinical context.", - }, - { - min: 7, - max: 13, - label: "Symptom threshold met", - tone: "warning", - guidance: "Symptom-count screen result; interpret with all required criteria and clinical context.", - }, - ], - }, { id: "cage", abbrev: "CAGE", @@ -556,132 +422,6 @@ export const calculators: CalculatorFixture[] = [ }, ], }, - { - id: "sadpersons", - abbrev: "SAD PERSONS", - name: "SAD PERSONS Scale", - domain: "risk", - icon: ShieldAlert, - indication: "Structure a rapid inventory of static suicide-risk factors as an adjunct to clinical assessment.", - summary: "10 yes/no risk factors, one point each; supports — never replaces — clinical judgement.", - timeEstimate: "1–2 min", - minScore: 0, - maxScore: 10, - scoringNote: "1 point per factor present. Bands are indicative only; act on clinical concern at any score.", - source: "Patterson et al. 1983", - caution: - "Risk scales have poor predictive value. Use to prompt a structured risk assessment, not to gate disposition decisions.", - items: [ - { id: "s1", kind: "checkbox", points: 1, text: "Sex — male" }, - { id: "s2", kind: "checkbox", points: 1, text: "Age — under 19 or over 45" }, - { id: "s3", kind: "checkbox", points: 1, text: "Depression — current low mood or diagnosed depressive episode" }, - { id: "s4", kind: "checkbox", points: 1, text: "Previous suicide attempt or psychiatric care" }, - { id: "s5", kind: "checkbox", points: 1, text: "Excess alcohol or substance use" }, - { id: "s6", kind: "checkbox", points: 1, text: "Rational thinking loss — psychosis or organic impairment" }, - { id: "s7", kind: "checkbox", points: 1, text: "Separated, widowed, or divorced" }, - { - id: "s8", - kind: "checkbox", - points: 1, - text: "Organised or serious attempt / plan", - flag: "Organised plan endorsed — escalate to a full structured risk assessment now.", - }, - { id: "s9", kind: "checkbox", points: 1, text: "No social supports" }, - { id: "s10", kind: "checkbox", points: 1, text: "Stated future intent to repeat or ambivalence about survival" }, - ], - bands: [ - { - min: 0, - max: 4, - label: "Lower indicative risk", - tone: "info", - guidance: "Lower score band; this instrument is not available for score-based decision support.", - }, - { - min: 5, - max: 6, - label: "Intermediate", - tone: "warning", - guidance: "Intermediate score band; this instrument is not available for score-based decision support.", - }, - { - min: 7, - max: 10, - label: "Higher indicative risk", - tone: "danger", - guidance: "Higher score band; this instrument is not available for score-based decision support.", - }, - ], - }, - { - id: "ybocs", - abbrev: "Y-BOCS", - name: "Yale-Brown Obsessive Compulsive Scale — Severity", - domain: "anxiety", - icon: Brain, - indication: "Grade the severity of obsessive-compulsive symptoms and track treatment response.", - summary: "10 severity items (0–4) across obsessions and compulsions; total 0–40.", - timeEstimate: "5–8 min", - minScore: 0, - maxScore: 40, - scoringNote: "Sum of 10 items. Items 1–5 grade obsessions, 6–10 compulsions; subscales can be reported separately.", - source: "Goodman et al. 1989", - items: [ - { id: "y1", kind: "options", options: ybocsSeverity, text: "Time occupied by obsessive thoughts" }, - { id: "y2", kind: "options", options: ybocsSeverity, text: "Interference from obsessive thoughts" }, - { id: "y3", kind: "options", options: ybocsSeverity, text: "Distress associated with obsessive thoughts" }, - { - id: "y4", - kind: "options", - options: ybocsResistance, - text: "Resistance against obsessions", - detail: "0 = always resists", - }, - { id: "y5", kind: "options", options: ybocsControl, text: "Degree of control over obsessive thoughts" }, - { id: "y6", kind: "options", options: ybocsSeverity, text: "Time spent performing compulsive behaviours" }, - { id: "y7", kind: "options", options: ybocsSeverity, text: "Interference from compulsive behaviours" }, - { id: "y8", kind: "options", options: ybocsSeverity, text: "Distress if compulsions are prevented" }, - { - id: "y9", - kind: "options", - options: ybocsResistance, - text: "Resistance against compulsions", - detail: "0 = always resists", - }, - { id: "y10", kind: "options", options: ybocsControl, text: "Degree of control over compulsive behaviour" }, - ], - bands: [ - { - min: 0, - max: 7, - label: "Subclinical", - tone: "success", - guidance: "Subclinical score band; interpret in clinical context.", - }, - { min: 8, max: 15, label: "Mild", tone: "info", guidance: "Mild score band; interpret in clinical context." }, - { - min: 16, - max: 23, - label: "Moderate", - tone: "warning", - guidance: "Moderate score band; interpret in clinical context.", - }, - { - min: 24, - max: 31, - label: "Severe", - tone: "danger", - guidance: "Severe score band; interpret in clinical context.", - }, - { - min: 32, - max: 40, - label: "Extreme", - tone: "danger", - guidance: "Extreme score band; interpret in clinical context.", - }, - ], - }, ]; export const domainOrder: CalculatorDomain[] = ["mood", "anxiety", "substance", "risk", "distress"]; diff --git a/src/components/calculators/bedside-sheet.tsx b/src/components/calculators/bedside-sheet.tsx index a0f75dc9fc..de60902586 100644 --- a/src/components/calculators/bedside-sheet.tsx +++ b/src/components/calculators/bedside-sheet.tsx @@ -29,7 +29,7 @@ function SheetSection({ onToggleOpen, onAnswersChange, }: { - calc: CalculatorFixture; + calc: CalculatorFixture & { disclaimer?: string }; answers: AnswerMap; open: boolean; onToggleOpen: () => void; @@ -125,6 +125,9 @@ function SheetSection({

{calc.scoringNote} · {calc.source}

+ {calc.disclaimer ? ( +

{calc.disclaimer}

+ ) : null}
) : null} @@ -142,8 +145,14 @@ export function CalculatorsBedsideSheetMockup() { .map((calc) => ({ calc, derived: deriveCalculator(calc, session[calc.id] ?? {}) })) .filter((entry) => entry.derived.started); + const completedCalcs = startedCalcs.filter((entry) => { + const canCopy = Boolean(entry?.derived.complete); + return canCopy; + }); + const handleCopy = async () => { - const lines = startedCalcs.map(({ calc, derived }) => formatResultSummary(calc, derived)); + if (!completedCalcs.length) return; + const lines = completedCalcs.map(({ calc, derived }) => formatResultSummary(calc, derived)); try { await navigator.clipboard.writeText(lines.join("\n")); setCopied(true); @@ -212,7 +221,11 @@ export function CalculatorsBedsideSheetMockup() { {/* Session summary dock — floats above the site composer */}
-
+
diff --git a/src/components/calculators/calculator-sheet.tsx b/src/components/calculators/calculator-sheet.tsx index a699abb930..8abc96a0e1 100644 --- a/src/components/calculators/calculator-sheet.tsx +++ b/src/components/calculators/calculator-sheet.tsx @@ -113,7 +113,7 @@ export function CalculatorSheet({ target.focus(); } }; - }, [calc.id, isOpen]); + }, [isOpen]); // Switching calculators in place keeps this sheet mounted, so reset its scroll // on calc change — otherwise the next one opens at the prior sheet's offset diff --git a/src/components/calculators/clinical-console.tsx b/src/components/calculators/clinical-console.tsx index 3d1f27b6c7..58ffd7fa46 100644 --- a/src/components/calculators/clinical-console.tsx +++ b/src/components/calculators/clinical-console.tsx @@ -87,7 +87,10 @@ export function CalculatorsClinicalConsoleMockup() { const [activeId, setActiveId] = useState("phq9"); const [session, setSession] = useState({}); - const calc = useMemo(() => calculators.find((entry) => entry.id === activeId) ?? calculators[0], [activeId]); + const calc: CalculatorFixture & { disclaimer?: string } = useMemo( + () => calculators.find((entry) => entry.id === activeId) ?? calculators[0], + [activeId], + ); const answers = session[calc.id] ?? {}; const derived = deriveCalculator(calc, answers); @@ -223,6 +226,10 @@ export function CalculatorsClinicalConsoleMockup() { {derived.started && derived.result.guidance ? (

{derived.result.guidance}

) : null} +

+ {calc.disclaimer ?? + "Clinical reference — not validated decision support. Confirm scoring and interpretation against the source instrument."} +

diff --git a/src/components/calculators/guided-flow.tsx b/src/components/calculators/guided-flow.tsx index 8c6019d296..936af69e34 100644 --- a/src/components/calculators/guided-flow.tsx +++ b/src/components/calculators/guided-flow.tsx @@ -250,7 +250,7 @@ function ResultScreen({ onRestart, onExit, }: { - calc: CalculatorFixture; + calc: CalculatorFixture & { disclaimer?: string }; answers: AnswerMap; onRestart: () => void; onExit: () => void; @@ -271,6 +271,10 @@ function ResultScreen({
+

+ {calc.disclaimer ?? + "Clinical reference — not validated decision support. Confirm scoring and interpretation against the source instrument."} +

{derived.result.guidance ? (

{derived.result.guidance} diff --git a/src/components/calculators/search-detail.tsx b/src/components/calculators/search-detail.tsx index 6d04b973dc..8b8bcc18f9 100644 --- a/src/components/calculators/search-detail.tsx +++ b/src/components/calculators/search-detail.tsx @@ -454,7 +454,7 @@ export function ScorePanel({ derived, onReset, }: { - calc: CalculatorFixture; + calc: CalculatorFixture & { disclaimer?: string }; derived: DerivedCalculator; onReset: () => void; }) { @@ -505,6 +505,9 @@ export function ScorePanel({ Governance reference:

) : null} + {calc.disclaimer ? ( +

{calc.disclaimer}

+ ) : null}
); @@ -551,7 +554,7 @@ function CalculatorDetail({ onAnswersChange, onBack, }: { - calc: CalculatorFixture; + calc: CalculatorFixture & { disclaimer?: string }; answers: AnswerMap; onAnswersChange: (next: AnswerMap) => void; onBack: () => void; @@ -596,6 +599,10 @@ function CalculatorDetail({ +

+ {calc.disclaimer ?? + "Clinical reference — not validated decision support. Confirm scoring and interpretation against the source instrument."} +

diff --git a/src/components/care-plan/mockups/care-plan.module.css b/src/components/care-plan/mockups/care-plan.module.css index a8f87e5354..ef79f6000c 100644 --- a/src/components/care-plan/mockups/care-plan.module.css +++ b/src/components/care-plan/mockups/care-plan.module.css @@ -414,7 +414,7 @@ .appRoot .definitionTerm { color: var(--text-muted); - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 700; letter-spacing: var(--tracking-label); text-transform: uppercase; diff --git a/src/components/care-plan/mockups/safety-plan-form.tsx b/src/components/care-plan/mockups/safety-plan-form.tsx index 75784665d2..6fc59e1995 100644 --- a/src/components/care-plan/mockups/safety-plan-form.tsx +++ b/src/components/care-plan/mockups/safety-plan-form.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ErrorSummary } from "@/components/ui/form-field"; import { Select } from "@/components/ui/select"; import { TextField } from "@/components/ui/text-field"; +import { useDirtyStateGuard } from "@/components/ui/use-dirty-state-guard"; import { EmptyState, InlineNotice, ignoreUnavailableActivation } from "@/components/ui-primitives"; import styles from "./care-plan.module.css"; @@ -269,6 +270,10 @@ export function SafetyPlanFormSurface({ setErrors([]); } + const baselineValues = draft === null ? EMPTY_FORM_VALUES : valuesFrom(draft); + const isDirty = draft !== null && JSON.stringify(values) !== JSON.stringify(baselineValues); + useDirtyStateGuard(isDirty); + /* * There is deliberately no focus effect here. `ErrorSummary` moves focus to * itself on a failed submit, which is the repository's behaviour for every diff --git a/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx b/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx index 4399bf95b6..7b21344ee8 100644 --- a/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx +++ b/src/components/clinical-dashboard/clinical-ask-answer-surface.tsx @@ -117,6 +117,10 @@ export function ClinicalAskAnswerSurface({ autoFocus={index === 0} name={item.id} required={item.required} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" value={clarificationAnswers[item.id] ?? ""} onChange={(event) => onClarificationChange?.(item.id, event.currentTarget.value)} /> @@ -312,7 +316,7 @@ function Evidence({
    {evidence.map((item) => (
  1. - + {item.title} {" "} — {item.publisher} · {item.tier} · {item.reviewState.replace("_", " ")} diff --git a/src/components/clinical-dashboard/lazy-sidebar-dialogs.tsx b/src/components/clinical-dashboard/lazy-sidebar-dialogs.tsx index 403ee55307..d89f11115b 100644 --- a/src/components/clinical-dashboard/lazy-sidebar-dialogs.tsx +++ b/src/components/clinical-dashboard/lazy-sidebar-dialogs.tsx @@ -1,7 +1,9 @@ "use client"; import dynamic from "next/dynamic"; -import type { ComponentProps } from "react"; +import { useEffect, useRef, useState, type ComponentProps } from "react"; + +import { restoreFocusUnlessMoved } from "@/components/use-dismissable-layer"; export const loadSettingsDialog = () => import("@/components/clinical-dashboard/settings-dialog").then((module) => module.SettingsDialog); @@ -19,9 +21,34 @@ export function prefetchAccountDialog() { } export function SidebarSettingsDialog(props: ComponentProps) { - return props.open ? : null; + const [hasOpened, setHasOpened] = useState(props.open); + const triggerRef = useRef(null); + if (props.open && !hasOpened) setHasOpened(true); + + useEffect(() => { + if (props.open) { + if ( + typeof document !== "undefined" && + document.activeElement instanceof HTMLElement && + document.activeElement !== document.body + ) { + triggerRef.current = document.activeElement; + } + return undefined; + } + const target = triggerRef.current; + if (!target) return undefined; + const frame = window.requestAnimationFrame(() => { + restoreFocusUnlessMoved(target); + }); + return () => window.cancelAnimationFrame(frame); + }, [props.open]); + + return hasOpened ? : null; } export function SidebarAccountSetupDialog(props: ComponentProps) { - return props.open ? : null; + const [hasOpened, setHasOpened] = useState(props.open); + if (props.open && !hasOpened) setHasOpened(true); + return hasOpened ? : null; } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 079b8740df..e962fe349d 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1603,24 +1603,18 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { // for the owning segment's hydration, just like portal adoption does. const syncReserve = (pending: boolean) => syncHydratedComposerReserve(composerSlotId, pending); - if (!composerSlotId || !searchComposerVisible) { - // No page-owned slot at this route, or the shell suppressed the composer: - // reset the portal state and collapse any SSR mode-home reserve band. - // Deferred to a microtask (not requestAnimationFrame) so it stays off the - // synchronous effect body without being frame-gated — headless CI can starve rAF. - let cancelled = false; - const observer = composerSlotId ? observeDesktopComposerSlots(() => syncReserve(false)) : null; - queueMicrotask(() => { - if (cancelled) return; - syncReserve(false); - setDesktopComposerPortalActive(false); - setDesktopComposerPortalHost(null); - setDesktopComposerPortalFallback(false); - setHomeComposerMediaEligible(false); - }); + let cancelled = false; + const resetPortal = () => { + syncReserve(false); + setDesktopComposerPortalActive(false); + setDesktopComposerPortalHost(null); + setDesktopComposerPortalFallback(false); + setHomeComposerMediaEligible(false); + }; + if (!composerSlotId) { + queueMicrotask(() => !cancelled && resetPortal()); return () => { cancelled = true; - observer?.disconnect(); }; } @@ -1633,17 +1627,18 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { // Hero slots retain their existing all/sm-up ownership. Generic page slots // start at sm so tablets and desktops share normal-flow search behaviour, // while phone docks remain unchanged. - const host = document.createElement("div"); - // Layout-transparent so the composer lays out as a direct child of the slot. - host.style.display = "contents"; - - const mediaQuery = window.matchMedia( - composerSlotKind === "home" - ? heroComposerBreakpoint === "sm-up" - ? modeHomeComposerSmUpMediaQuery - : modeHomeComposerMediaQuery - : desktopPageComposerMediaQuery, - ); + const host = searchComposerVisible ? document.createElement("div") : null; + if (host) host.style.display = "contents"; + + const mediaQuery = searchComposerVisible + ? window.matchMedia( + composerSlotKind === "home" + ? heroComposerBreakpoint === "sm-up" + ? modeHomeComposerSmUpMediaQuery + : modeHomeComposerMediaQuery + : desktopPageComposerMediaQuery, + ) + : null; let retryTimeout: number | null = null; let portalFailureStartedAt: number | null = null; @@ -1659,6 +1654,10 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { // their React segment hydrates. Adopting the slot before that injects a // display:contents host into still-unhydrated RSC HTML (React #418). const syncTarget = () => { + if (!searchComposerVisible || !host || !mediaQuery) { + syncReserve(false); + return; + } if (composerSlotKind === "home") { setHomeComposerMediaEligible(mediaQuery.matches); } @@ -1721,28 +1720,24 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { }; const observer = observeDesktopComposerSlots(syncTarget); - syncTarget(); - mediaQuery.addEventListener("change", syncTarget); + if (!searchComposerVisible) { + queueMicrotask(() => !cancelled && resetPortal()); + } else { + syncTarget(); + mediaQuery?.addEventListener("change", syncTarget); + } return () => { + cancelled = true; if (retryTimeout !== null) window.clearTimeout(retryTimeout); observer.disconnect(); - mediaQuery.removeEventListener("change", syncTarget); - host.parentNode?.removeChild(host); - if (composerSlotId) { - // Ready-gate, same as adoption: only a slot whose own segment has - // hydrated is ours to clear. This cleanup also runs on an ordinary - // dependency re-run, and stripping the SSR marker there leaves a window - // in which the DOM lacks an attribute the page's client render still - // produces — a page segment hydrating inside it logs a - // `data-composer-reserve` mismatch (client "pending" vs server null). - // Hydration readiness is observed by the effect body, so its pending - // clear still runs once the owning segment is safe to mutate. A real - // unmount takes the slot element with it. + mediaQuery?.removeEventListener("change", syncTarget); + host?.parentNode?.removeChild(host); + if (searchComposerVisible) { syncHydratedComposerReserve(composerSlotId, false); + setDesktopComposerPortalActive(false); + setDesktopComposerPortalHost(null); + setDesktopComposerPortalFallback(false); } - setDesktopComposerPortalActive(false); - setDesktopComposerPortalHost(null); - setDesktopComposerPortalFallback(false); }; }, [desktopHomeComposerSlotId, desktopPageComposerSlotId, heroComposerBreakpoint, searchComposerVisible]); @@ -2370,6 +2365,10 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { id="global-search-input" name="query" data-testid="global-search-input" + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" autoFocus={queryInputAutoFocus} disabled={searchSetupNotReady} title={searchSetupNotReady ? "Search setup not ready" : undefined} @@ -2757,7 +2756,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { onClick={() => setModeSheetView("modes")} aria-label="Back to all modes" data-testid="app-mode-popover-back" - className="grid size-8 shrink-0 place-items-center rounded-md text-[color:var(--text-muted)] transition-colors hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--focus)]" + className="grid size-tap shrink-0 place-items-center rounded-md text-[color:var(--text-muted)] transition-colors hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--focus)]" >
diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx index 4d63b70974..449a7dc2f6 100644 --- a/src/components/clinical-dashboard/settings-dialog.tsx +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -21,6 +21,7 @@ import { BookOpen, BriefcaseBusiness, Check, + ChevronDown, ChevronRight, CloudOff, FlaskConical, @@ -78,7 +79,14 @@ import { subscribePatientProfile, } from "@/lib/patient-profile-storage"; import { clearRecentQueries, countRecentQueries } from "@/lib/recent-query-storage"; -import { cn, floatingControl, InlineNotice, primaryControl, toggleThumbSurface } from "@/components/ui-primitives"; +import { + cn, + fieldControl, + floatingControl, + InlineNotice, + primaryControl, + toggleThumbSurface, +} from "@/components/ui-primitives"; import { ProviderBrandMark, type SsoProvider } from "@/components/clinical-dashboard/provider-brand-icons"; import { ReminderSettingsBlock } from "@/components/clinical-dashboard/settings-reminders"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -86,6 +94,7 @@ import { Select } from "@/components/ui/select"; import { Sheet } from "@/components/ui/sheet"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { TextField } from "@/components/ui/text-field"; +import { restoreFocusUnlessMoved } from "@/components/use-dismissable-layer"; import { matchingSettingsRowIds, matchingSettingsSectionIds, @@ -279,6 +288,7 @@ export function SettingsDialog({ }) { const closeButtonRef = useRef(null); const guideButtonRef = useRef(null); + const triggerRef = useRef(null); const scrollRef = useRef(null); // The title bar is sticky inside the scroll region on every breakpoint, so its // height is the amount of the scroll port a section would otherwise land @@ -723,6 +733,28 @@ export function SettingsDialog({ // visible work if the email form is already open. }, [emailEntryOpen, accountEntryMode]); + useEffect(() => { + if (open) { + if ( + typeof document !== "undefined" && + document.activeElement instanceof HTMLElement && + document.activeElement !== document.body && + document.activeElement !== closeButtonRef.current && + document.activeElement !== guideButtonRef.current && + !scrollRef.current?.contains(document.activeElement) + ) { + triggerRef.current = document.activeElement; + } + return undefined; + } + const target = triggerRef.current; + if (!target) return undefined; + const frame = window.requestAnimationFrame(() => { + restoreFocusUnlessMoved(target); + }); + return () => window.cancelAnimationFrame(frame); + }, [open]); + const closeButton = (