+
+
+
+ );
+}
diff --git a/src/components/mental-health/check-in-nav.ts b/src/components/mental-health/check-in-nav.ts
new file mode 100644
index 000000000..125d04a6b
--- /dev/null
+++ b/src/components/mental-health/check-in-nav.ts
@@ -0,0 +1,71 @@
+/**
+ * v1.25.3 — pure step grammar for the one-question-at-a-time check-in.
+ *
+ * The PHQ-9 / GAD-7 screeners are LINEAR (no mode/cadence branching like the
+ * medication wizard), so the step list is a fixed `[1..itemCount, review]` and
+ * the reachability ceiling is just "the question you are currently working on"
+ * — you can always step backward to change an answer, never skip ahead onto an
+ * unanswered question. Keeping the navigation here (mirroring the medication
+ * wizard's `wizard-payload.ts`) makes the advance/back/reachability contract
+ * unit-testable without driving a DOM.
+ *
+ * Step numbers are 1-based and run `1..itemCount` for the questions; the review
+ * step is `itemCount + 1`. The optional functional-difficulty control folds
+ * into the review step (it is unscored), so the dot count stays at
+ * `itemCount + 1` rather than gaining its own dot.
+ *
+ * An unanswered item carries the sentinel `-1`.
+ */
+
+export const UNANSWERED = -1;
+
+/** The 1-based review step number for an instrument of `itemCount` items. */
+export function reviewStep(itemCount: number): number {
+ return itemCount + 1;
+}
+
+/** Ordered raw step numbers fed straight to `WizardStepper`: questions + review. */
+export function buildStepList(itemCount: number): number[] {
+ return Array.from({ length: itemCount + 1 }, (_, i) => i + 1);
+}
+
+/** Index (into the step list) of the highest forward-reachable slot. */
+export function reachableUntilIndex(items: readonly number[]): number {
+ const firstUnanswered = items.findIndex((v) => v < 0);
+ // All answered → the review slot (last index) is reachable.
+ if (firstUnanswered === -1) return items.length;
+ // Otherwise the frontier is the question currently being answered; its index
+ // in the step list equals its 0-based item index.
+ return firstUnanswered;
+}
+
+/** Every item answered (0–3) — gates the review step + submit. */
+export function isComplete(items: readonly number[]): boolean {
+ return items.length > 0 && items.every((v) => v >= 0);
+}
+
+/** How many items carry a real answer (for the review recap). */
+export function answeredCount(items: readonly number[]): number {
+ return items.filter((v) => v >= 0).length;
+}
+
+/**
+ * The next step after `step`. A question advances to the next question, the
+ * last question advances to the review step, and the review step stays put
+ * (submit, not advance).
+ */
+export function nextStep(step: number, itemCount: number): number {
+ const review = reviewStep(itemCount);
+ if (step >= review) return review;
+ return step + 1;
+}
+
+/** The previous step, clamped at the first question. */
+export function prevStep(step: number): number {
+ return step > 1 ? step - 1 : 1;
+}
+
+/** Whether `step` is the review step for this instrument. */
+export function isReviewStep(step: number, itemCount: number): boolean {
+ return step >= reviewStep(itemCount);
+}
diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx
new file mode 100644
index 000000000..8d337b859
--- /dev/null
+++ b/src/components/mental-health/check-in-wizard.tsx
@@ -0,0 +1,281 @@
+"use client";
+
+/**
+ * v1.25.3 — one-question-at-a-time check-in.
+ *
+ * Replaces the all-questions-at-once `` wall with a linear stepper that
+ * reuses the medication wizard's presentational `WizardStepper` (the dot row +
+ * "Question 3 of 9" caption) and its Back/Next footer grammar — rendered INLINE
+ * on the page (not a modal), because here the page *is* the surface. The step
+ * navigation lives in `check-in-nav.ts` (pure, linear: backward always allowed,
+ * never skip ahead to an unanswered question).
+ *
+ * Header: instrument title + ONE standardized explanation line, NO disclaimer
+ * (§2 — the "voluntary self-test, not a diagnosis" disclaimer renders only on
+ * the landing + behind the InfoHint, never while taking the test). The optional
+ * functional-difficulty control folds into the review step.
+ */
+import { useEffect, useRef, useState } from "react";
+import { ArrowLeft } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { WizardStepper } from "@/components/medications/wizard/wizard-stepper";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import { INSTRUMENTS } from "@/lib/mental-health/instruments";
+
+import {
+ UNANSWERED,
+ answeredCount,
+ buildStepList,
+ isComplete,
+ isReviewStep,
+ nextStep,
+ prevStep,
+ reachableUntilIndex,
+} from "./check-in-nav";
+import type { InstrumentId } from "./types";
+
+const SCALE = [0, 1, 2, 3] as const;
+const FUNCTIONAL = [0, 1, 2, 3] as const;
+
+function lower(id: InstrumentId): "phq9" | "gad7" {
+ return id === "PHQ9" ? "phq9" : "gad7";
+}
+
+export function CheckInWizard({
+ instrument,
+ onSubmit,
+ onBack,
+ isPending,
+ isError,
+}: {
+ instrument: InstrumentId;
+ /** Submit the completed check-in. `functional` is null when unanswered. */
+ onSubmit: (items: number[], functional: number | null) => void;
+ /** Return to the landing (abandons the in-progress check-in). */
+ onBack: () => void;
+ isPending: boolean;
+ isError: boolean;
+}) {
+ const { t } = useTranslations();
+ const key = lower(instrument);
+ const itemCount = INSTRUMENTS[instrument].itemCount;
+
+ const [step, setStep] = useState(1);
+ const [items, setItems] = useState(() =>
+ Array(itemCount).fill(UNANSWERED),
+ );
+ const [functional, setFunctional] = useState(null);
+
+ const stepList = buildStepList(itemCount);
+ const onReview = isReviewStep(step, itemCount);
+ const questionIndex = step - 1; // 0-based item index for a question step
+ const complete = isComplete(items);
+ const reachableUntil = reachableUntilIndex(items);
+
+ // a11y: announce each step by moving focus to its heading (the question text
+ // / the review title). Mirrors the medication dialog's per-step title focus
+ // so a keyboard / screen-reader user hears the new question after an
+ // auto-advance, not silence.
+ const headingRef = useRef(null);
+ useEffect(() => {
+ headingRef.current?.focus({ preventScroll: false });
+ }, [step]);
+
+ function selectAnswer(value: number) {
+ setItems((prev) => {
+ const next = [...prev];
+ next[questionIndex] = value;
+ return next;
+ });
+ // Forward-on-select: advance to the next question, or onto the review step
+ // from the last question. The footer Next is the redundant keyboard path.
+ setStep((s) => nextStep(s, itemCount));
+ }
+
+ const srLabel = onReview
+ ? t("mentalHealth.review.title")
+ : t("mentalHealth.questionAria", { current: step, total: itemCount });
+
+ return (
+
+
+
+ {/* In-test header — instrument title + ONE standardized explanation line.
+ No disclaimer here (§2); the (i) tooltip on the landing carries it. */}
+
+
+
+ {/* The optional, unscored functional-difficulty follow-up folds
+ into the review step rather than its own dot (§1). */}
+
+
+ {t("mentalHealth.functionalTitle")}
+
+
+ {FUNCTIONAL.map((v) => (
+
+ ))}
+
+
+
+ {isError && (
+
+ {t("mentalHealth.error")}
+
+ )}
+
+ )}
+
+
+
+
+ {!onReview ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/mental-health/crisis-card.tsx b/src/components/mental-health/crisis-card.tsx
new file mode 100644
index 000000000..d10aaee09
--- /dev/null
+++ b/src/components/mental-health/crisis-card.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+/**
+ * v1.25.3 — the calm, non-alarmist crisis-resource card.
+ *
+ * Shared between the fresh-result view (the POST response carries the crisis
+ * set on any non-zero PHQ-9 item 9) and the history list, where tapping a
+ * flagged row re-surfaces this card from the STATIC, locale-derived resource
+ * config (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the
+ * item-9 answer, and showing it never triggers any third-party alert.
+ *
+ * Tone follows the v1.25.1 decision: a calm `border-destructive/40` border,
+ * never an amber/red fill; the `role="alert"` + `aria-live` carry the urgency.
+ */
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useTranslations } from "@/lib/i18n/context";
+
+import type { CrisisSet } from "./types";
+
+export function CrisisCard({ crisis }: { crisis: CrisisSet }) {
+ const { t } = useTranslations();
+ return (
+
+
+
+ {t("mentalHealth.crisis.title")}
+
+
+
+