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/developer-area-access.md b/docs/developer-area-access.md index e7af956637..68d0b9daa0 100644 --- a/docs/developer-area-access.md +++ b/docs/developer-area-access.md @@ -73,6 +73,11 @@ sent onward. The field submits with `location.replace`, so the URL that briefly carries the secret is not left as a history entry either. From then on that browser opens the developer area with no sign-in at all. +The response carries separate `Set-Cookie` headers for `/admin/developer-hub`, +`/mockups/development`, and `/mockups/care-plan`. They share the developer cookie +name; each header is appended separately so Next.js does not overwrite the +other paths. Renewal uses the same path scopes. + **The key travels in a URL either way, so rotate it after exporting logs.** Typed or bookmarked, the secret is presented as `?devkey=…` on one request. The proxy redirects it straight back out, so it does not persist in the address bar 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/agreement-ask/agreement-ask-page.tsx b/src/components/agreement-ask/agreement-ask-page.tsx index 007ca6768f..a8d7701b4c 100644 --- a/src/components/agreement-ask/agreement-ask-page.tsx +++ b/src/components/agreement-ask/agreement-ask-page.tsx @@ -269,6 +269,7 @@ export function AgreementAskPage() {
{ event.preventDefault(); 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/calculator-mockups/calculator-pathways.ts b/src/components/calculator-mockups/calculator-pathways.ts index c83bde6efb..eefcd0570f 100644 --- a/src/components/calculator-mockups/calculator-pathways.ts +++ b/src/components/calculator-mockups/calculator-pathways.ts @@ -76,7 +76,6 @@ const pathways: Record = { href: "/?mode=answer&q=ECT+consent+requirements", minBandIndex: 4, }, - { title: "MDQ — bipolar screen before antidepressants", kind: "calculator", calcId: "mdq", minBandIndex: 2 }, ], }, gad7: { @@ -114,23 +113,6 @@ const pathways: Record = { { title: "GAD-7 — anxiety severity", kind: "calculator", calcId: "gad7", minBandIndex: 2 }, ], }, - mdq: { - related: [ - { - title: "Bipolar disorder — assessment and referral", - kind: "guideline", - href: "/documents/search?q=bipolar+disorder+assessment", - }, - { - title: "Lithium — initiation and monitoring", - kind: "medication", - href: "/medications/lithium-carbonate-ir-sr", - minBandIndex: 1, - }, - { title: "Elevated mood — differential diagnoses", kind: "differential", href: "/differentials" }, - { title: "PHQ-9 — current depressive severity", kind: "calculator", calcId: "phq9" }, - ], - }, cage: { related: [ { title: "AUDIT-C — consumption screen", kind: "calculator", calcId: "auditc" }, @@ -160,39 +142,6 @@ const pathways: Record = { }, ], }, - sadpersons: { - related: [ - { - title: "Suicide risk assessment framework", - kind: "guideline", - href: "/documents/search?q=suicide+risk+assessment+framework", - }, - { title: "Safety planning template", kind: "form", href: "/forms" }, - { title: "13YARN — crisis support referral", kind: "service", href: "/services" }, - { title: "Acute mental health team", kind: "service", href: "/services", minBandIndex: 1 }, - ], - }, - ybocs: { - related: [ - { - title: "OCD — exposure and response prevention", - kind: "guideline", - href: "/documents/search?q=OCD+exposure+response+prevention", - }, - { - title: "Sertraline — higher-dose OCD treatment", - kind: "medication", - href: "/medications/sertraline", - minBandIndex: 2, - }, - { - title: "SSRI augmentation in OCD", - kind: "answer", - href: "/?mode=answer&q=SSRI+augmentation+OCD", - minBandIndex: 3, - }, - ], - }, }; /** Actions for the current band — falls back to the band's guidance sentence. */ diff --git a/src/components/calculators/bedside-sheet.tsx b/src/components/calculators/bedside-sheet.tsx index a0f75dc9fc..b6f06065e7 100644 --- a/src/components/calculators/bedside-sheet.tsx +++ b/src/components/calculators/bedside-sheet.tsx @@ -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..15d3127249 100644 --- a/src/components/calculators/calculator-sheet.tsx +++ b/src/components/calculators/calculator-sheet.tsx @@ -112,8 +112,15 @@ export function CalculatorSheet({ if (target && target.isConnected && typeof target.focus === "function" && canRestoreFocusTo(target)) { target.focus(); } + if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(() => { + if (target && target.isConnected && typeof target.focus === "function" && canRestoreFocusTo(target)) { + 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..10af18be3e 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 = 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..7a95f6cc1c 100644 --- a/src/components/calculators/guided-flow.tsx +++ b/src/components/calculators/guided-flow.tsx @@ -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..41a9153f6f 100644 --- a/src/components/calculators/search-detail.tsx +++ b/src/components/calculators/search-detail.tsx @@ -505,6 +505,9 @@ export function ScorePanel({ Governance reference:

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

{calc.disclaimer}

+ ) : null}
); @@ -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..6810e8be2d 100644 --- a/src/components/clinical-dashboard/lazy-sidebar-dialogs.tsx +++ b/src/components/clinical-dashboard/lazy-sidebar-dialogs.tsx @@ -1,7 +1,7 @@ "use client"; import dynamic from "next/dynamic"; -import type { ComponentProps } from "react"; +import { useState, type ComponentProps } from "react"; export const loadSettingsDialog = () => import("@/components/clinical-dashboard/settings-dialog").then((module) => module.SettingsDialog); @@ -19,9 +19,13 @@ export function prefetchAccountDialog() { } export function SidebarSettingsDialog(props: ComponentProps) { - return props.open ? : null; + const [hasOpened, setHasOpened] = useState(props.open); + if (props.open && !hasOpened) setHasOpened(true); + 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)]" >