= {
+ success: "bg-[color:var(--success)]",
+ info: "bg-[color:var(--info)]",
+ warning: "bg-[color:var(--warning)]",
+ danger: "bg-[color:var(--danger)]",
+};
+
+export function SeverityPill({ tone, label, className }: { tone: CalculatorTone; label: string; className?: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+/**
+ * Horizontal severity spectrum: one segment per band (width proportional to
+ * its score range) with a marker at the current score.
+ */
+export function ScoreBandBar({
+ calc,
+ score,
+ started,
+ className,
+}: {
+ calc: CalculatorFixture;
+ score: number;
+ started: boolean;
+ className?: string;
+}) {
+ const span = calc.maxScore - calc.minScore || 1;
+ const fraction = Math.min(1, Math.max(0, (score - calc.minScore) / span));
+
+ return (
+
+
+ {calc.bands.map((band) => (
+
+ ))}
+ {started ? (
+
+ ) : null}
+
+
+ {calc.minScore}
+ {calc.maxScore}
+
+
+ );
+}
+
+export function BandLegend({ calc, activeBand }: { calc: CalculatorFixture; activeBand?: ScoreBand }) {
+ return (
+
+ {calc.bands.map((band) => {
+ const active = activeBand === band;
+ return (
+
+
+
+ {band.min}–{band.max}
+
+
+ {band.label}
+
+
+ );
+ })}
+
+ );
+}
+
+/* ---------- interactive item controls ---------- */
+
+export function CheckboxRow({
+ item,
+ checked,
+ onToggle,
+ index,
+ dense = false,
+}: {
+ item: CalculatorItem;
+ checked: boolean;
+ onToggle: () => void;
+ index?: number;
+ dense?: boolean;
+}) {
+ return (
+
+
+ {checked ? : null}
+
+
+
+ {index !== undefined ? (
+
+ {index}.
+
+ ) : null}
+ {item.text}
+
+ {item.detail ? (
+
+ {item.detail}
+
+ ) : null}
+
+
+ );
+}
+
+export function OptionScale({
+ item,
+ value,
+ onSelect,
+ layout = "row",
+}: {
+ item: CalculatorItem;
+ value: number | undefined;
+ onSelect: (optionIndex: number) => void;
+ /** row = compact single line of numbered chips; stack = full-label buttons. */
+ layout?: "row" | "stack";
+}) {
+ const options = item.options ?? [];
+ const showPoints = options.some((option) => option.points !== 0);
+
+ if (layout === "stack") {
+ return (
+
+ {options.map((option, optionIndex) => {
+ const active = value === optionIndex;
+ return (
+ onSelect(optionIndex)}
+ className={cn(
+ "grid min-h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-lg border px-3 text-left text-sm font-semibold transition",
+ active
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+ {option.label}
+ {showPoints ? (
+
+ {option.points}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+ }
+
+ return (
+
+ {options.map((option, optionIndex) => {
+ const active = value === optionIndex;
+ return (
+ onSelect(optionIndex)}
+ className={cn(
+ "inline-flex min-h-tap min-w-tap items-center justify-center rounded-lg border px-2.5 text-sm-minus font-bold transition",
+ active
+ ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]",
+ focusRing,
+ )}
+ >
+ {option.short}
+
+ );
+ })}
+
+ );
+}
+
+export function FlagNotice({ flags }: { flags: string[] }) {
+ if (!flags.length) return null;
+ return (
+
+ {flags.map((flag) => (
+
+
+ {flag}
+
+ ))}
+
+ );
+}
+
+export function ResetButton({ onReset, disabled }: { onReset: () => void; disabled?: boolean }) {
+ return (
+
+
+ Clear
+
+ );
+}
+
+export function progressLabel(state: DerivedCalculator): string {
+ if (state.optionItemCount === 0) return `${state.checkedCount} of ${state.checkboxItemCount} endorsed`;
+ const answered = `${state.answeredCount} of ${state.optionItemCount} answered`;
+ return state.checkboxItemCount > 0 ? `${answered} · ${state.checkedCount} endorsed` : answered;
+}
+
+/** Compact metadata chip: item count, time estimate, score range. */
+export function MetaPill({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+/** Copy-to-clipboard button with its own "Copied" feedback state. */
+export function CopyResultButton({
+ calc,
+ state,
+ label = "Copy result",
+ className,
+}: {
+ calc: CalculatorFixture;
+ state: DerivedCalculator;
+ label?: string;
+ className?: string;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(formatResultSummary(calc, state));
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ } catch {
+ /* clipboard unavailable in some embeds — mockup-safe no-op */
+ }
+ };
+
+ return (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied" : label}
+
+ );
+}
+
+/** One-line result summary used by every copy-to-clipboard affordance. */
+export function formatResultSummary(calc: CalculatorFixture, state: DerivedCalculator): string {
+ return `${calc.abbrev} ${state.score}/${calc.maxScore} — ${state.result.label}${
+ state.complete ? "" : ` (${progressLabel(state)})`
+ }`;
+}
+
+/**
+ * The single option set shared by every options item, or null when items
+ * carry bespoke option sets (then items render stacked full labels instead
+ * of numbered chips plus one response key).
+ */
+export function sharedOptionKey(calc: CalculatorFixture) {
+ const optionItems = calc.items.filter((item) => item.kind === "options");
+ if (optionItems.length < 2) return null;
+ const first = optionItems[0].options;
+ return optionItems.every((item) => item.options === first) ? (first ?? null) : null;
+}
+
+export function ResponseKey({ calc }: { calc: CalculatorFixture }) {
+ const key = sharedOptionKey(calc);
+ if (!key) return null;
+ return (
+
+ {key.map((option) => (
+
+ {option.short}
+ {option.label}
+
+ ))}
+
+ );
+}
+
+/**
+ * Full interactive item list for a calculator: response key, stem, then a
+ * CheckboxRow or OptionScale per item. Uniform scales get numbered chips
+ * with one key; bespoke option sets get stacked full-label buttons.
+ */
+export function CalculatorItems({
+ calc,
+ answers,
+ onAnswersChange,
+ dense = false,
+ showKey = true,
+}: {
+ calc: CalculatorFixture;
+ answers: AnswerMap;
+ onAnswersChange: (next: AnswerMap) => void;
+ dense?: boolean;
+ showKey?: boolean;
+}) {
+ const key = sharedOptionKey(calc);
+ // Offer an explicit "all remaining negative" affordance for checkbox-only
+ // scales, so an all-negative CAGE/SAD PERSONS can be completed without ticking
+ // each box — but only on user action, never by merely opening the scale.
+ const canMarkRemaining = isCheckboxOnly(calc) && calc.items.some((item) => answers[item.id] === undefined);
+
+ return (
+
+ {showKey ? : null}
+ {calc.stem ? (
+ {calc.stem}
+ ) : null}
+ {calc.items.map((item, itemIndex) =>
+ item.kind === "checkbox" ? (
+ onAnswersChange(toggleCheckboxAnswer(answers, item.id))}
+ dense={dense}
+ />
+ ) : (
+
+
+
+
+ {itemIndex + 1}.
+
+ {item.text}
+
+ {item.detail ? (
+
{item.detail}
+ ) : null}
+
+
onAnswersChange(selectOptionAnswer(answers, item.id, optionIndex))}
+ layout={key ? "row" : "stack"}
+ />
+
+ ),
+ )}
+ {canMarkRemaining ? (
+ onAnswersChange(seedCheckboxDefaults(calc, answers))}
+ className={cn(
+ "mt-1 inline-flex min-h-10 items-center justify-center gap-2 rounded-lg border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface-subtle)] px-3 text-sm-minus font-bold text-[color:var(--text-muted)] transition hover:border-[color:var(--clinical-accent-border)] hover:text-[color:var(--text)]",
+ focusRing,
+ )}
+ >
+
+ Mark remaining as “No”
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/calculator-mockups/clinical-console-mockup.tsx b/src/components/calculator-mockups/clinical-console-mockup.tsx
new file mode 100644
index 000000000..8114e153b
--- /dev/null
+++ b/src/components/calculator-mockups/clinical-console-mockup.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import { AlertTriangle, Info, Stethoscope } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+import { calculators, domainIcons, domainLabels, domainOrder, type CalculatorFixture } from "./calculator-fixtures";
+import {
+ CalculatorItems,
+ CopyResultButton,
+ FlagNotice,
+ ScoreBandBar,
+ SeverityPill,
+ deriveCalculator,
+ focusRing,
+ progressLabel,
+ toneBar,
+ type AnswerMap,
+} from "./calculator-ui";
+
+type SessionAnswers = Record;
+
+function RailEntry({
+ calc,
+ active,
+ answers,
+ onSelect,
+}: {
+ calc: CalculatorFixture;
+ active: boolean;
+ answers: AnswerMap;
+ onSelect: () => void;
+}) {
+ const derived = deriveCalculator(calc, answers);
+
+ return (
+ 0 ? "grid-cols-[minmax(0,1fr)_auto_auto]" : "grid-cols-[minmax(0,1fr)_auto]",
+ active
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]"
+ : "border-transparent hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+
+
+ {calc.abbrev}
+
+
+ {calc.summary}
+
+
+ {derived.started ? (
+
+
+
+ {derived.score}
+
+
+ ) : (
+
+ {calc.items.length} items
+
+ )}
+ {derived.flags.length > 0 ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+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 answers = session[calc.id] ?? {};
+ const derived = deriveCalculator(calc, answers);
+
+ const setAnswers = (next: AnswerMap) => setSession((prev) => ({ ...prev, [calc.id]: next }));
+
+ return (
+
+
+
+
+
+
+
+
+
+ Calculator console
+
+
+ Work through scales side by side — progress is kept per calculator.
+
+
+
+
+ Session only · nothing stored
+
+
+
+
+
+ {/* Rail — horizontal chips on phones, grouped list on desktop */}
+
+
+ {calculators.map((entry) => {
+ const entryDerived = deriveCalculator(entry, session[entry.id] ?? {});
+ const active = entry.id === calc.id;
+ return (
+
setActiveId(entry.id)}
+ className={cn(
+ "inline-flex min-h-10 shrink-0 items-center gap-2 rounded-md border px-3 text-sm-minus font-bold",
+ active
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
+ focusRing,
+ )}
+ >
+ {entry.abbrev}
+ {entryDerived.started ? (
+
+ ) : null}
+ {entryDerived.flags.length > 0 ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ {domainOrder.map((domain) => {
+ const entries = calculators.filter((entry) => entry.domain === domain);
+ if (!entries.length) return null;
+ const DomainIcon = domainIcons[domain];
+ return (
+
+
+
+ {domainLabels[domain]}
+
+ {entries.map((entry) => (
+ setActiveId(entry.id)}
+ />
+ ))}
+
+ );
+ })}
+
+
+
+ {/* Workspace */}
+
+
+
+
{calc.abbrev}
+ {calc.name}
+
+
+
+ {calc.indication}
+
+ {calc.caution ? (
+ {calc.caution}
+ ) : null}
+
+
+ {/* Sticky live-score ticker */}
+
+
+
+
+ {derived.started ? derived.score : "—"}
+ / {calc.maxScore}
+
+
+ {progressLabel(derived)}
+
+
+
+
+ {derived.started && derived.result.guidance ? (
+ {derived.result.guidance}
+ ) : null}
+
+
+
+
+
+
+
+
+ {calc.scoringNote} {calc.source}
+
+ setAnswers({})}
+ disabled={!derived.started}
+ className={cn(
+ "inline-flex min-h-9 items-center rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-2xs font-bold text-[color:var(--text-muted)] hover:text-[color:var(--text)] disabled:pointer-events-none disabled:opacity-40",
+ focusRing,
+ )}
+ >
+ Clear {calc.abbrev}
+
+
+
+
+
+ );
+}
diff --git a/src/components/calculator-mockups/directory-grid-mockup.tsx b/src/components/calculator-mockups/directory-grid-mockup.tsx
new file mode 100644
index 000000000..7be202e52
--- /dev/null
+++ b/src/components/calculator-mockups/directory-grid-mockup.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import { BookOpen, Calculator, ChevronDown, Clock3, Info, ListChecks, Search, ShieldCheck, Sigma } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+import {
+ calculators,
+ domainLabels,
+ domainOrder,
+ plannedCalculators,
+ type CalculatorDomain,
+ type CalculatorFixture,
+} from "./calculator-fixtures";
+import {
+ BandLegend,
+ CalculatorItems,
+ CopyResultButton,
+ FlagNotice,
+ MetaPill,
+ ResetButton,
+ ScoreBandBar,
+ SeverityPill,
+ deriveCalculator,
+ focusRing,
+ progressLabel,
+ type AnswerMap,
+} from "./calculator-ui";
+
+type DomainFilter = CalculatorDomain | "all";
+
+function ExpandedCalculator({
+ calc,
+ answers,
+ onAnswersChange,
+}: {
+ calc: CalculatorFixture;
+ answers: AnswerMap;
+ onAnswersChange: (next: AnswerMap) => void;
+}) {
+ const state = deriveCalculator(calc, answers);
+
+ return (
+
+
+
+
+
+
+
+ Score
+
+
+ {state.started ? state.score : "—"}
+ / {calc.maxScore}
+
+
+
+
+
+ {progressLabel(state)}
+ {state.started && state.result.guidance ? (
+
+ {state.result.guidance}
+
+ ) : null}
+
+
+
+
+
+ {calc.scoringNote}
+
+ {calc.caution ? (
+
{calc.caution}
+ ) : null}
+
+
+ onAnswersChange({})} disabled={!state.started} />
+
+
{calc.source}
+
+
+
+ );
+}
+
+function CalculatorCard({
+ calc,
+ open,
+ onToggle,
+ answers,
+ onAnswersChange,
+}: {
+ calc: CalculatorFixture;
+ open: boolean;
+ onToggle: () => void;
+ answers: AnswerMap;
+ onAnswersChange: (next: AnswerMap) => void;
+}) {
+ const Icon = calc.icon;
+
+ return (
+
+
+
+
+
+
+
+ {calc.abbrev}
+
+ {domainLabels[calc.domain]}
+
+
+
+ {calc.name}
+
+
+ {calc.indication}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {open ? : null}
+
+ );
+}
+
+const filterChips: { id: DomainFilter; label: string }[] = [
+ { id: "all", label: "All" },
+ ...domainOrder.map((domain) => ({ id: domain as DomainFilter, label: domainLabels[domain] })),
+];
+
+export function CalculatorsDirectoryGridMockup() {
+ const [query, setQuery] = useState("");
+ const [domain, setDomain] = useState("all");
+ const [openId, setOpenId] = useState("cage");
+ // Answers live here, keyed by calculator id, so they survive a card collapsing
+ // or another card opening (which unmounts ExpandedCalculator).
+ const [session, setSession] = useState>({});
+
+ const visible = useMemo(() => {
+ const trimmed = query.trim().toLowerCase();
+ return calculators.filter((calc) => {
+ if (domain !== "all" && calc.domain !== domain) return false;
+ if (!trimmed) return true;
+ const haystack = [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(trimmed) || calc.items.some((item) => item.text.toLowerCase().includes(trimmed));
+ });
+ }, [domain, query]);
+
+ return (
+
+
+
+
+
+ {visible.map((calc) => (
+ setOpenId((prev) => (prev === calc.id ? null : calc.id))}
+ answers={session[calc.id] ?? {}}
+ onAnswersChange={(next) => setSession((prev) => ({ ...prev, [calc.id]: next }))}
+ />
+ ))}
+ {!visible.length ? (
+
+ No calculators match that search.
+
+ ) : null}
+
+
+
+
+
+
Coming next
+
+
+ {plannedCalculators.map((planned) => {
+ const Icon = planned.icon;
+ return (
+
+
+
+
+
+
+ {planned.abbrev}
+
+ Planned
+
+
+
+ {planned.indication}
+
+
+
+ );
+ })}
+
+
+
+
+
+ Scores support clinical judgement — they never replace a full assessment. Nothing entered here is stored.
+
+
+
+ );
+}
diff --git a/src/components/calculator-mockups/guided-flow-mockup.tsx b/src/components/calculator-mockups/guided-flow-mockup.tsx
new file mode 100644
index 000000000..9198f8133
--- /dev/null
+++ b/src/components/calculator-mockups/guided-flow-mockup.tsx
@@ -0,0 +1,387 @@
+"use client";
+
+import { ArrowLeft, ArrowRight, Check, ChevronRight, Clock3, ListChecks, RotateCcw, X } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+import { calculators, domainLabels, type CalculatorFixture, type CalculatorItem } from "./calculator-fixtures";
+import {
+ BandLegend,
+ CopyResultButton,
+ FlagNotice,
+ ScoreBandBar,
+ SeverityPill,
+ deriveCalculator,
+ focusRing,
+ itemScore,
+ type AnswerMap,
+} from "./calculator-ui";
+
+function PickerScreen({ onPick }: { onPick: (id: string) => void }) {
+ return (
+
+
+
Guided calculators
+
+ One question at a time, sized for the ward round. Pick a scale to begin.
+
+
+
+ {calculators.map((calc) => {
+ const Icon = calc.icon;
+ return (
+ onPick(calc.id)}
+ className={cn(
+ "grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 text-left shadow-[var(--shadow-inset)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:shadow-[var(--shadow-soft)]",
+ focusRing,
+ )}
+ >
+
+
+
+
+
+
+ {calc.abbrev}
+
+
+ {domainLabels[calc.domain]}
+
+
+
+ {calc.indication}
+
+
+
+
+ {calc.items.length} questions
+
+
+
+ {calc.timeEstimate}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function QuestionScreen({
+ calc,
+ item,
+ stepIndex,
+ answers,
+ onAnswer,
+ onBack,
+ onExit,
+}: {
+ calc: CalculatorFixture;
+ item: CalculatorItem;
+ stepIndex: number;
+ answers: AnswerMap;
+ onAnswer: (value: number) => void;
+ onBack: () => void;
+ onExit: () => void;
+}) {
+ const derived = deriveCalculator(calc, answers);
+ const total = calc.items.length;
+ const value = answers[item.id];
+
+ return (
+
+
+
+
+
+
+
{calc.abbrev}
+
+ {stepIndex + 1} / {total}
+
+
+
+ {derived.started ? derived.score : 0}
+
+
+
+
+
+
+ {calc.stem ? (
+
+ {calc.stem}
+
+ ) : null}
+
{item.text}
+ {item.detail ? (
+
{item.detail}
+ ) : null}
+
+
+ {item.kind === "checkbox" ? (
+ <>
+ onAnswer(1)}
+ className={cn(
+ "grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-lg border px-4 text-left text-base font-bold transition",
+ value === 1
+ ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-heading)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+
+ Yes
+
+ onAnswer(0)}
+ className={cn(
+ "grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-lg border px-4 text-left text-base font-bold transition",
+ value === 0
+ ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-heading)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+
+ No
+
+ >
+ ) : (
+ (item.options ?? []).map((option, optionIndex) => {
+ const active = value === optionIndex;
+ const showPoints = (item.options ?? []).some((entry) => entry.points !== 0);
+ return (
+ onAnswer(optionIndex)}
+ className={cn(
+ "grid min-h-14 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border px-4 text-left text-base-minus font-bold transition",
+ active
+ ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-heading)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+ {option.label}
+ {showPoints ? (
+
+ +{option.points}
+
+ ) : null}
+
+ );
+ })
+ )}
+
+
+
+
+
+
+ Back
+
+
Tap an answer to continue
+
+
+ );
+}
+
+function ResultScreen({
+ calc,
+ answers,
+ onRestart,
+ onExit,
+}: {
+ calc: CalculatorFixture;
+ answers: AnswerMap;
+ onRestart: () => void;
+ onExit: () => void;
+}) {
+ const derived = deriveCalculator(calc, answers);
+
+ return (
+
+
+
+ {calc.abbrev} result
+
+
+ {derived.score}
+ / {calc.maxScore}
+
+
+
+
+
+ {derived.result.guidance ? (
+
+ {derived.result.guidance}
+
+ ) : null}
+
+
+
+
+
+
+ Answer review
+
+
+ {calc.items.map((item, itemIndex) => {
+ const value = answers[item.id];
+ const answerLabel =
+ item.kind === "checkbox" ? (value === 1 ? "Yes" : "No") : (item.options?.[value ?? -1]?.label ?? "—");
+ const points = itemScore(item, value);
+ return (
+
+
+ {itemIndex + 1}.
+
+ {item.text}
+
+ {answerLabel}
+
+ +{points}
+
+
+
+ );
+ })}
+
+
+
+
+
+ {calc.caution ? (
+
{calc.caution}
+ ) : null}
+
{calc.source}
+
+
+
+
+
+ Start again
+
+
+ Another calculator
+
+
+
+
+ );
+}
+
+export function CalculatorsGuidedFlowMockup() {
+ const [activeId, setActiveId] = useState(null);
+ const [stepIndex, setStepIndex] = useState(0);
+ const [answers, setAnswers] = useState({});
+
+ const calc = useMemo(() => calculators.find((entry) => entry.id === activeId) ?? null, [activeId]);
+ const finished = calc !== null && stepIndex >= calc.items.length;
+
+ const start = (id: string) => {
+ setActiveId(id);
+ setStepIndex(0);
+ setAnswers({});
+ };
+
+ const exit = () => {
+ setActiveId(null);
+ setStepIndex(0);
+ setAnswers({});
+ };
+
+ const answer = (item: CalculatorItem, value: number) => {
+ setAnswers((prev) => ({ ...prev, [item.id]: value }));
+ setStepIndex((prev) => prev + 1);
+ };
+
+ return (
+
+
+ {!calc ? (
+
+ ) : finished ? (
+ start(calc.id)} onExit={exit} />
+ ) : (
+ answer(calc.items[stepIndex], value)}
+ onBack={() => setStepIndex((prev) => Math.max(0, prev - 1))}
+ onExit={exit}
+ />
+ )}
+
+
+ );
+}
diff --git a/src/components/calculator-mockups/index.ts b/src/components/calculator-mockups/index.ts
new file mode 100644
index 000000000..74d5c5023
--- /dev/null
+++ b/src/components/calculator-mockups/index.ts
@@ -0,0 +1,7 @@
+export { CalculatorsBedsideSheetMockup } from "./bedside-sheet-mockup";
+export { CalculatorsClinicalConsoleMockup } from "./clinical-console-mockup";
+export { CalculatorsDirectoryGridMockup } from "./directory-grid-mockup";
+export { CalculatorsGuidedFlowMockup } from "./guided-flow-mockup";
+export { CalculatorsPopupSheetMockup } from "./popup-sheet-mockup";
+export { CalculatorsSearchPageMockup } from "./search-page-mockup";
+export { CalculatorsSearchDetailMockup } from "./search-detail-mockup";
diff --git a/src/components/calculator-mockups/popup-sheet-mockup.tsx b/src/components/calculator-mockups/popup-sheet-mockup.tsx
new file mode 100644
index 000000000..701b70e30
--- /dev/null
+++ b/src/components/calculator-mockups/popup-sheet-mockup.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import { Info, X } from "lucide-react";
+import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+import { calculators, domainLabels, type CalculatorFixture } from "./calculator-fixtures";
+import {
+ CalculatorItems,
+ ScoreBandBar,
+ SeverityPill,
+ deriveCalculator,
+ focusRing,
+ progressLabel,
+ type AnswerMap,
+} from "./calculator-ui";
+import {
+ CalculatorSearchHome,
+ NextActionsPanel,
+ RelatedContentPanel,
+ ScorePanel,
+ type SessionAnswers,
+} from "./search-detail-mockup";
+
+/**
+ * Popup variant of the search flow: the individual calculator opens as a
+ * modal dialog on desktop and a bottom sheet on phones, keeping the search
+ * page in place underneath. Uses the app's modal layer (z-100) and the
+ * sheet-up / dialog-rise motion tokens.
+ */
+export function CalculatorSheet({
+ calc,
+ answers,
+ onAnswersChange,
+ onClose,
+ onOpenCalculator,
+}: {
+ calc: CalculatorFixture;
+ answers: AnswerMap;
+ onAnswersChange: (next: AnswerMap) => void;
+ onClose: () => void;
+ onOpenCalculator: (calcId: string) => void;
+}) {
+ const derived = deriveCalculator(calc, answers);
+ const closeRef = useRef(null);
+ const dialogRef = useRef(null);
+ const scrollRef = useRef(null);
+ const previousFocusRef = useRef(null);
+ const Icon = calc.icon;
+
+ // Save the opener, move focus into the dialog, and restore on close.
+ useEffect(() => {
+ previousFocusRef.current = document.activeElement as HTMLElement | null;
+ closeRef.current?.focus();
+ return () => previousFocusRef.current?.focus?.();
+ }, [calc.id]);
+
+ // 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
+ // (e.g. partway down the related-content rows) instead of its indication.
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: 0 });
+ }, [calc.id]);
+
+ // Trap Tab / Shift+Tab within the dialog so focus can't reach the page behind.
+ const trapTab = (event: ReactKeyboardEvent) => {
+ if (event.key !== "Tab") return;
+ const root = dialogRef.current;
+ if (!root) return;
+ const focusables = Array.from(
+ root.querySelectorAll(
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ ),
+ ).filter(
+ (element) => element.tabIndex !== -1 && (element.offsetParent !== null || element === document.activeElement),
+ );
+ if (!focusables.length) return;
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+ const active = document.activeElement;
+ if (event.shiftKey && active === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+
+ return (
+
+
+
+
+
+ {/* Live strip pinned under the header while items scroll */}
+
+
+
+ {derived.started ? derived.score : "—"}
+ / {calc.maxScore}
+
+
+ {progressLabel(derived)}
+
+
+
+
+
+
+
+
+
+ {calc.indication}
+
+ {calc.caution ? (
+
{calc.caution}
+ ) : null}
+
+
+
+
+
+
+
+
+
onAnswersChange({})} />
+
+
+
+ );
+}
+
+export function CalculatorsPopupSheetMockup() {
+ const [openId, setOpenId] = useState(null);
+ const [session, setSession] = useState({});
+
+ const activeCalc = openId ? calculators.find((calc) => calc.id === openId) : undefined;
+
+ useEffect(() => {
+ if (!activeCalc) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpenId(null);
+ };
+ window.addEventListener("keydown", onKey);
+ document.body.style.overflow = "hidden";
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ document.body.style.overflow = "";
+ };
+ }, [activeCalc]);
+
+ return (
+
+
+ {activeCalc ? (
+ setSession((prev) => ({ ...prev, [activeCalc.id]: next }))}
+ onClose={() => setOpenId(null)}
+ onOpenCalculator={setOpenId}
+ />
+ ) : null}
+
+ );
+}
diff --git a/src/components/calculator-mockups/search-detail-mockup.tsx b/src/components/calculator-mockups/search-detail-mockup.tsx
new file mode 100644
index 000000000..2e831ad14
--- /dev/null
+++ b/src/components/calculator-mockups/search-detail-mockup.tsx
@@ -0,0 +1,698 @@
+"use client";
+
+import {
+ AlertTriangle,
+ ArrowLeft,
+ ArrowRight,
+ ArrowUpRight,
+ Calculator,
+ Clock3,
+ History,
+ Info,
+ ListChecks,
+ Search,
+ Sigma,
+ Sparkles,
+} from "lucide-react";
+import Link from "next/link";
+import { useMemo, useState } from "react";
+
+import { ModeHomeHero } from "@/components/mode-home-template";
+import { chatComposerInput, chatComposerShell, chatSendButton, cn, eyebrowText } from "@/components/ui-primitives";
+
+import {
+ calculators,
+ domainLabels,
+ domainOrder,
+ type CalculatorDomain,
+ type CalculatorFixture,
+} from "./calculator-fixtures";
+import {
+ actionsForBand,
+ relatedForBand,
+ relatedKindLabels,
+ type RelatedItem,
+ type RelatedKind,
+} from "./calculator-pathways";
+import {
+ BandLegend,
+ CalculatorItems,
+ CopyResultButton,
+ MetaPill,
+ ResetButton,
+ ScoreBandBar,
+ SeverityPill,
+ deriveCalculator,
+ focusRing,
+ progressLabel,
+ toneBar,
+ type AnswerMap,
+ type DerivedCalculator,
+} from "./calculator-ui";
+
+type DomainFilter = CalculatorDomain | "all";
+export type SessionAnswers = Record;
+
+const relatedKindChip: Record = {
+ guideline:
+ "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]",
+ medication:
+ "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]",
+ differential:
+ "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]",
+ service:
+ "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]",
+ form: "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]",
+ answer: "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]",
+ calculator:
+ "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
+};
+
+/** First item whose text matches the query — shown as match context in results. */
+function matchContext(calc: CalculatorFixture, query: string): string | null {
+ const matched = calc.items.find((item) => item.text.toLowerCase().includes(query));
+ return matched ? matched.text : null;
+}
+
+function StartedChip({ derived }: { derived: DerivedCalculator }) {
+ if (!derived.started) return null;
+ return (
+
+ {derived.score}
+ {derived.result.label}
+
+ );
+}
+
+/* ---------- search view ---------- */
+
+function CalculatorResultCard({
+ calc,
+ derived,
+ onOpen,
+}: {
+ calc: CalculatorFixture;
+ derived: DerivedCalculator;
+ onOpen: () => void;
+}) {
+ const Icon = calc.icon;
+
+ return (
+
+
+
+
+
+
+
+ {calc.abbrev}
+
+ {domainLabels[calc.domain]}
+
+
+
+ {calc.name}
+
+
+
+
+
+ {calc.indication}
+
+
+
+
+
+
+
+
+ );
+}
+
+function CalculatorResultRow({
+ calc,
+ derived,
+ context,
+ onOpen,
+}: {
+ calc: CalculatorFixture;
+ derived: DerivedCalculator;
+ context: string | null;
+ onOpen: () => void;
+}) {
+ const Icon = calc.icon;
+
+ return (
+
+
+
+
+
+
+ {calc.abbrev}
+ {calc.name}
+
+
+
+ {calc.indication}
+
+ {context ? (
+
+
+
+ Matches item: “{context}”
+
+
+ ) : null}
+
+
+
+ );
+}
+
+const filterChips: { id: DomainFilter; label: string }[] = [
+ { id: "all", label: "All" },
+ ...domainOrder.map((domain) => ({ id: domain as DomainFilter, label: domainLabels[domain] })),
+];
+
+/**
+ * The calculators search home: mode-home hero, composer-pill search, domain
+ * filters, continue strip, and the card grid / live results. Reused by both
+ * the full-page detail flow and the popup-sheet variant.
+ */
+export function CalculatorSearchHome({
+ session,
+ onOpen,
+}: {
+ session: SessionAnswers;
+ onOpen: (calcId: string) => void;
+}) {
+ const [query, setQuery] = useState("");
+ const [domain, setDomain] = useState("all");
+
+ const trimmed = query.trim().toLowerCase();
+
+ const results = useMemo(() => {
+ return calculators
+ .filter((calc) => domain === "all" || calc.domain === domain)
+ .map((calc) => ({
+ calc,
+ context: trimmed ? matchContext(calc, trimmed) : null,
+ matches:
+ !trimmed ||
+ [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]]
+ .join(" ")
+ .toLowerCase()
+ .includes(trimmed) ||
+ calc.items.some((item) => item.text.toLowerCase().includes(trimmed)),
+ }))
+ .filter((entry) => entry.matches);
+ }, [domain, trimmed]);
+
+ const inProgress = calculators
+ .map((calc) => ({ calc, derived: deriveCalculator(calc, session[calc.id] ?? {}) }))
+ .filter((entry) => entry.derived.started);
+
+ return (
+
+
+
+
+
+
+ {filterChips.map((chip) => {
+ const active = domain === chip.id;
+ return (
+ setDomain(chip.id)}
+ className={cn(
+ "inline-flex min-h-tap shrink-0 items-center rounded-lg border px-3 text-sm-minus font-bold transition lg:min-h-9",
+ active
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
+ focusRing,
+ )}
+ >
+ {chip.label}
+
+ );
+ })}
+
+
+ {!trimmed && inProgress.length ? (
+
+
+
+ Continue this session
+
+
+ {inProgress.map(({ calc, derived }) => (
+ onOpen(calc.id)}
+ className={cn(
+ "inline-flex min-h-tap shrink-0 items-center gap-2 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-sm-minus font-bold text-[color:var(--clinical-accent)] transition hover:bg-[color:var(--clinical-accent-soft)]/70",
+ focusRing,
+ )}
+ >
+
+ {calc.abbrev}
+
+ {derived.score}/{calc.maxScore}
+
+ {progressLabel(derived)}
+
+ ))}
+
+
+ ) : null}
+
+ {trimmed ? (
+
+
+ {results.length} {results.length === 1 ? "calculator" : "calculators"} for “{query.trim()}”
+
+ {results.map(({ calc, context }) => (
+ onOpen(calc.id)}
+ />
+ ))}
+ {!results.length ? (
+
+
No calculator matches that.
+
+ Try a symptom (“hopeless”, “drinking”) or ask Clinical Guide below.
+
+
+ ) : null}
+
+ ) : (
+
+ All calculators
+
+ {results.map(({ calc }) => (
+ onOpen(calc.id)}
+ />
+ ))}
+
+
+ )}
+
+
+ Scores support clinical judgement — they never replace a full assessment. Nothing entered here is stored.
+
+
+ );
+}
+
+/* ---------- detail view: score-linked panels ---------- */
+
+export function NextActionsPanel({ calc, derived }: { calc: CalculatorFixture; derived: DerivedCalculator }) {
+ const actions = actionsForBand(calc, derived);
+
+ return (
+
+
+
Next clinical actions
+
+
+ Score-linked
+
+
+
+ {!derived.started ? (
+
+ Answer the items and recommendations for the scored severity band appear here.
+
+ ) : (
+ <>
+ {derived.flags.map((flag) => (
+
+
+ {flag}
+
+ ))}
+
+ {actions.map((action, actionIndex) => (
+
+
+ {actionIndex + 1}
+
+
+
+ {action.label}
+
+ {action.detail ? (
+
+ {action.detail}
+
+ ) : null}
+
+
+ ))}
+
+ {derived.band ? (
+
+ For
+ — updates automatically as the
+ score changes.
+
+ ) : null}
+ >
+ )}
+
+ );
+}
+
+export function RelatedContentPanel({
+ calc,
+ derived,
+ onOpenCalculator,
+}: {
+ calc: CalculatorFixture;
+ derived: DerivedCalculator;
+ onOpenCalculator: (calcId: string) => void;
+}) {
+ const visible = relatedForBand(calc, derived);
+ const all = relatedForBand(calc, { ...derived, band: calc.bands[calc.bands.length - 1] });
+ const moreAtHigherSeverity = all.length > visible.length;
+
+ if (!all.length) return null;
+
+ const rowClass = cn(
+ "grid w-full min-h-tap grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-2 text-left transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ );
+
+ const rowBody = (item: RelatedItem) => (
+ <>
+
+ {relatedKindLabels[item.kind]}
+
+
+
+ {item.title}
+
+ {item.note ? (
+
+ {item.note}
+
+ ) : null}
+
+
+ >
+ );
+
+ return (
+
+ From the knowledge base
+
+ {visible.map((item) =>
+ item.kind === "calculator" && item.calcId ? (
+ onOpenCalculator(item.calcId as string)}
+ className={rowClass}
+ >
+ {rowBody(item)}
+
+ ) : (
+
+ {rowBody(item)}
+
+ ),
+ )}
+
+ {moreAtHigherSeverity ? (
+
+ More referral and treatment content surfaces at higher severity bands.
+
+ ) : null}
+
+ );
+}
+
+export function ScorePanel({
+ calc,
+ derived,
+ onReset,
+}: {
+ calc: CalculatorFixture;
+ derived: DerivedCalculator;
+ onReset: () => void;
+}) {
+ return (
+
+
+
+
Score
+
+ {derived.started ? derived.score : "—"}
+ / {calc.maxScore}
+
+
+
+
+
+ {progressLabel(derived)}
+
+
+
+
+ {calc.scoringNote}
+
+
+
+
+
+
{calc.source}
+
+
+ );
+}
+
+/* ---------- detail view ---------- */
+
+export function CalculatorDetailHeader({ calc }: { calc: CalculatorFixture }) {
+ return (
+
+
+
+
+
+
+
+
{calc.abbrev}
+
+ {domainLabels[calc.domain]}
+
+
+
{calc.name}
+
+
+
+
+
+
+
+
+
+ {calc.indication}
+
+ {calc.caution ? (
+ {calc.caution}
+ ) : null}
+
+ );
+}
+
+function CalculatorDetail({
+ calc,
+ answers,
+ onAnswersChange,
+ onBack,
+ onOpenCalculator,
+}: {
+ calc: CalculatorFixture;
+ answers: AnswerMap;
+ onAnswersChange: (next: AnswerMap) => void;
+ onBack: () => void;
+ onOpenCalculator: (calcId: string) => void;
+}) {
+ const derived = deriveCalculator(calc, answers);
+
+ return (
+
+
+
+
+ All calculators
+
+
+ Session only · nothing stored
+
+
+
+
+
+ {/* Compact live ticker — phones only; desktop has the sticky rail */}
+
+
+
+ {derived.started ? derived.score : "—"}
+ / {calc.maxScore}
+
+
+
+
+
+
+
+
+
+ onAnswersChange({})} />
+
+
+
+
+
+ );
+}
+
+/* ---------- page ---------- */
+
+/**
+ * Reset scroll to the top on open/back. On phones the shell's #main-content is
+ * the scroll container (max-sm:overflow-y-auto), so window.scrollTo alone leaves
+ * the detail view at the prior offset — reset both the shell scroller and the
+ * window (desktop) so the header/back control is always in view.
+ */
+function resetDetailScroll() {
+ document.getElementById("main-content")?.scrollTo({ top: 0 });
+ window.scrollTo({ top: 0 });
+}
+
+export function CalculatorsSearchDetailMockup() {
+ const [openId, setOpenId] = useState(null);
+ const [session, setSession] = useState({});
+
+ const openCalculator = (calcId: string) => {
+ setOpenId(calcId);
+ resetDetailScroll();
+ };
+
+ const activeCalc = openId ? calculators.find((calc) => calc.id === openId) : undefined;
+
+ return (
+
+ {activeCalc ? (
+ setSession((prev) => ({ ...prev, [activeCalc.id]: next }))}
+ onBack={() => {
+ setOpenId(null);
+ resetDetailScroll();
+ }}
+ onOpenCalculator={openCalculator}
+ />
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/calculator-mockups/search-page-mockup.tsx b/src/components/calculator-mockups/search-page-mockup.tsx
new file mode 100644
index 000000000..fe55cf58b
--- /dev/null
+++ b/src/components/calculator-mockups/search-page-mockup.tsx
@@ -0,0 +1,686 @@
+"use client";
+
+import {
+ ArrowRight,
+ Calculator,
+ Clock3,
+ History,
+ Info,
+ LayoutGrid,
+ ListChecks,
+ Plus,
+ Rows3,
+ Search,
+ Send,
+ Sigma,
+ SlidersHorizontal,
+ X,
+} from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips";
+import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout";
+import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll";
+import { PrivacyInputNotice } from "@/components/privacy-input-notice";
+import { chatComposerInput, chatComposerShellBase, chatSendButton, cn, eyebrowText } from "@/components/ui-primitives";
+
+import {
+ calculators,
+ domainIcons,
+ domainLabels,
+ domainOrder,
+ plannedCalculators,
+ type CalculatorDomain,
+ type CalculatorFixture,
+} from "./calculator-fixtures";
+import {
+ MetaPill,
+ SeverityPill,
+ deriveCalculator,
+ focusRing,
+ progressLabel,
+ toneBar,
+ type AnswerMap,
+ type DerivedCalculator,
+} from "./calculator-ui";
+import { CalculatorSheet } from "./popup-sheet-mockup";
+
+type DomainFilter = CalculatorDomain | "all";
+type SessionAnswers = Record;
+type Density = "comfortable" | "compact";
+
+/** Match context: name / indication hit, or the first matching item text. */
+function matchContext(calc: CalculatorFixture, query: string): string | null {
+ if (!query) return null;
+ const item = calc.items.find((entry) => entry.text.toLowerCase().includes(query));
+ return item ? item.text : null;
+}
+
+function matches(calc: CalculatorFixture, query: string): boolean {
+ if (!query) return true;
+ const haystack = [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(query) || calc.items.some((item) => item.text.toLowerCase().includes(query));
+}
+
+/* ---------- universal-style search composer (top on desktop, docked bottom on phones) ---------- */
+
+// Example searches shown in the composer prompt row; each filters the list.
+const promptExamples = ["depression", "anxiety", "drinking", "bipolar", "suicide"];
+
+/**
+ * The calculators search composer, matching the app's universal composer: a
+ * leading "+" (new search), the query input with an inline clear, and the teal
+ * send button. `variant="full"` adds the Smart-search hint, prompt chips, and
+ * privacy notice (desktop header); `variant="compact"` shows the pill plus the
+ * privacy line only (phone bottom dock).
+ */
+function CalculatorComposer({
+ query,
+ onQuery,
+ onReset,
+ onSubmit,
+ variant,
+}: {
+ query: string;
+ onQuery: (value: string) => void;
+ onReset: () => void;
+ onSubmit: () => void;
+ variant: "full" | "compact";
+}) {
+ return (
+
+ {variant === "full" ? (
+
+ Smart search
+ ·
+
+ Try “depression severity” in Calculators.
+
+
+ ) : null}
+
+
+
+ {variant === "full" ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+/* ---------- home-page-style calculator tile ---------- */
+
+function CalculatorTile({
+ calc,
+ derived,
+ context,
+ compact,
+ onOpen,
+}: {
+ calc: CalculatorFixture;
+ derived: DerivedCalculator;
+ context: string | null;
+ compact: boolean;
+ onOpen: () => void;
+}) {
+ const Icon = calc.icon;
+
+ return (
+
+
+
+
+
+
+
+ {calc.abbrev}
+
+ {domainLabels[calc.domain]}
+
+ {derived.started ? (
+
+ ) : null}
+
+
+ {calc.name}
+
+ {compact ? null : (
+
+ {calc.indication}
+
+ )}
+
+
+
+ {compact ? null : }
+
+ {context ? (
+
+
+
+ Matches item: “{context}”
+
+
+ ) : null}
+
+
+
+
+ );
+}
+
+/* ---------- results header band (count + eyebrow + controls) ---------- */
+
+function ResultsHeaderBand({
+ count,
+ query,
+ density,
+ onDensity,
+}: {
+ count: number;
+ query: string;
+ density: Density;
+ onDensity: (next: Density) => void;
+}) {
+ return (
+
+
+
+
+ {count}
+
+
+
+ Clinical calculators
+
+
+ {count} {count === 1 ? "calculator" : "calculators"}
+
+
+ {query ? (
+ <>
+ Matching “{query} ”. Open one to score
+ it and see next actions.
+ >
+ ) : (
+ "Validated psychiatry scores. Open one to score it and see score-linked next actions."
+ )}
+
+
+
+
+ {(
+ [
+ ["comfortable", LayoutGrid, "Comfortable"],
+ ["compact", Rows3, "Compact"],
+ ] as const
+ ).map(([value, DensityIcon, label]) => {
+ const active = density === value;
+ return (
+ onDensity(value)}
+ className={cn(
+ "grid size-9 place-items-center rounded-md transition",
+ active
+ ? "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "text-[color:var(--text-soft)] hover:text-[color:var(--text)]",
+ focusRing,
+ )}
+ >
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+/* ---------- right rail ---------- */
+
+function DomainNav({
+ domain,
+ counts,
+ onSelect,
+}: {
+ domain: DomainFilter;
+ counts: Record;
+ onSelect: (next: DomainFilter) => void;
+}) {
+ const rows: { id: DomainFilter; label: string; icon: typeof Calculator }[] = [
+ { id: "all", label: "All calculators", icon: Calculator },
+ ...domainOrder.map((entry) => ({
+ id: entry as DomainFilter,
+ label: domainLabels[entry],
+ icon: domainIcons[entry],
+ })),
+ ];
+
+ return (
+
+ Browse by domain
+
+ {rows.map((row) => {
+ const active = domain === row.id;
+ const RowIcon = row.icon;
+ const count = row.id === "all" ? calculators.length : (counts[row.id] ?? 0);
+ return (
+ onSelect(row.id)}
+ className={cn(
+ "grid min-h-tap grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 rounded-lg border px-2.5 text-left transition",
+ active
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]"
+ : "border-transparent hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+
+
+ {row.label}
+
+ {count}
+
+ );
+ })}
+
+
+ );
+}
+
+function ContinuePanel({
+ inProgress,
+ onOpen,
+}: {
+ inProgress: { calc: CalculatorFixture; derived: DerivedCalculator }[];
+ onOpen: (calcId: string) => void;
+}) {
+ if (!inProgress.length) return null;
+ return (
+
+
+
+ Continue this session
+
+
+ {inProgress.map(({ calc, derived }) => (
+
onOpen(calc.id)}
+ className={cn(
+ "grid min-h-14 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-left transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+
+
+
+ {calc.abbrev}
+
+ {derived.score}/{calc.maxScore}
+
+
+
+ {progressLabel(derived)} · {derived.result.label}
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function AboutPanel() {
+ return (
+
+
+
+ About these tools
+
+
+ Scores support clinical judgement — they never replace a full assessment. Every calculator cites its source and
+ maps its result to next clinical actions. Nothing you enter is stored.
+
+
+ {plannedCalculators.length} more calculators (CIWA-Ar, EPDS, COWS) are coming next.
+
+
+ );
+}
+
+/* ---------- page ---------- */
+
+const filterChips: { id: DomainFilter; label: string }[] = [
+ { id: "all", label: "All" },
+ ...domainOrder.map((domain) => ({ id: domain as DomainFilter, label: domainLabels[domain] })),
+];
+
+export function CalculatorsSearchPageMockup() {
+ const [query, setQuery] = useState("");
+ const [domain, setDomain] = useState("all");
+ const [density, setDensity] = useState("comfortable");
+ const [session, setSession] = useState({});
+ const [openId, setOpenId] = useState(null);
+
+ const trimmed = query.trim().toLowerCase();
+
+ const domainCounts = useMemo(() => {
+ const counts: Record = {};
+ for (const calc of calculators) counts[calc.domain] = (counts[calc.domain] ?? 0) + 1;
+ return counts;
+ }, []);
+
+ const results = useMemo(
+ () =>
+ calculators
+ .filter((calc) => (domain === "all" || calc.domain === domain) && matches(calc, trimmed))
+ .map((calc) => ({ calc, context: matchContext(calc, trimmed) })),
+ [domain, trimmed],
+ );
+
+ const inProgress = useMemo(
+ () =>
+ calculators
+ .map((calc) => ({ calc, derived: deriveCalculator(calc, session[calc.id] ?? {}) }))
+ .filter((entry) => entry.derived.started),
+ [session],
+ );
+
+ const activeCalc = openId ? calculators.find((calc) => calc.id === openId) : undefined;
+
+ useEffect(() => {
+ if (!activeCalc) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpenId(null);
+ };
+ window.addEventListener("keydown", onKey);
+ document.body.style.overflow = "hidden";
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ document.body.style.overflow = "";
+ };
+ }, [activeCalc]);
+
+ // Hide the bottom composer dock on scroll-down in lockstep with the shell's
+ // top header, using the same hook (identical thresholds, phone-only, inert on
+ // desktop). On phones the shell's #main-content owns vertical scroll
+ // (max-sm:overflow-y-auto) and drives the header hide; the inner
+ // searchPageShell has no vertical overflow, so it never scrolls. Point
+ // the hook at #main-content so the dock reacts to the same scroll events. The
+ // hook polls the ref until the shell element resolves.
+ const scrollContainerRef = useRef(null);
+ useEffect(() => {
+ scrollContainerRef.current = document.querySelector("#main-content");
+ }, []);
+ const footerHidden = useHideOnScroll({ containerRef: scrollContainerRef });
+ // Keep the phone dock visible while focused so scroll-hide cannot slide a
+ // focused input off-screen or mark it aria-hidden while still tabbable.
+ const [dockFocused, setDockFocused] = useState(false);
+ const dockHidden = footerHidden && !dockFocused;
+
+ const compact = density === "compact";
+
+ const submitSearch = () => {
+ if (results.length === 1) setOpenId(results[0].calc.id);
+ };
+
+ const resetSearch = () => {
+ setQuery("");
+ setDomain("all");
+ };
+
+ return (
+ <>
+
+ {/* Desktop: universal-style composer at the top, matching the site-wide
+ search header. Phones get the docked bottom composer below. */}
+
+
+
+
+
+
+ {filterChips.map((chip) => {
+ const active = domain === chip.id;
+ return (
+ setDomain(chip.id)}
+ className={cn(
+ "inline-flex min-h-9 shrink-0 items-center rounded-full border px-3 text-xs font-bold transition",
+ active
+ ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)]",
+ focusRing,
+ )}
+ >
+ {chip.label}
+
+ );
+ })}
+
+
+
+ Filters
+
+
+
+ }
+ summary={
+
+ }
+ sidebar={
+ <>
+
+
+
+ >
+ }
+ sidebarMobile={
+
+ }
+ >
+ {results.length ? (
+
+ {results.map(({ calc, context }) => (
+ setOpenId(calc.id)}
+ />
+ ))}
+
+ ) : (
+
+
+
+
+
+ No calculators match “{query.trim()}”.
+
+
+ Try a symptom (“hopeless”, “drinking”, “worry”) or clear the filters.
+
+
{
+ setQuery("");
+ setDomain("all");
+ }}
+ className={cn(
+ "mt-1 inline-flex min-h-10 items-center gap-2 rounded-lg bg-[color:var(--clinical-accent)] px-4 text-sm-minus font-bold text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)] hover:bg-[color:var(--clinical-accent-hover)]",
+ focusRing,
+ )}
+ >
+ Reset search
+
+
+ )}
+
+
+ {/* Phones: composer docks at the bottom, matching the site-wide composer
+ placement, and slides away on scroll-down in lockstep with the header.
+ Hidden while a calculator sheet is open. */}
+ {activeCalc ? null : (
+ setDockFocused(true)}
+ onBlurCapture={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDockFocused(false);
+ }}
+ className={cn(
+ "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--border)] bg-[color:var(--surface-glass)] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 backdrop-blur-md transition-transform duration-200 ease-out motion-reduce:transition-none sm:hidden",
+ dockHidden ? "translate-y-full" : "translate-y-0",
+ )}
+ aria-hidden={dockHidden}
+ inert={dockHidden || undefined}
+ >
+
+
+ )}
+
+ {activeCalc ? (
+ setSession((prev) => ({ ...prev, [activeCalc.id]: next }))}
+ onClose={() => setOpenId(null)}
+ onOpenCalculator={setOpenId}
+ />
+ ) : null}
+ >
+ );
+}