+ {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."
+ )}
+
+ 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 footerHidden = useMockupHideOnScroll();
+ // 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. */}
+
+ Try a symptom (“hopeless”, “drinking”, “worry”) or clear the filters.
+
+
+
+ )}
+
+
+ {/* 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 : (
+
+ );
+}
+
+const PHONE_STATES: Array<{ state: PhoneState; label: string; note: string }> = [
+ {
+ state: "rest",
+ label: "At rest",
+ note: "Two lines and a weighted track replace seven pills. Nothing scrolls sideways, and the compare basket finally has a permanent readout.",
+ },
+ {
+ state: "sheet",
+ label: "Sheet open",
+ note: "Viewport-anchored, so the scroll signal never touches it. Opening it blurs the composer so hide-on-scroll can reclaim both edges afterwards.",
+ },
+ {
+ state: "hidden",
+ label: "Scrolled — chrome gone",
+ note: "Row, track and composer leave together and release every reserve to 0 rem. While they are away the section heading in the content is what names your place.",
+ },
+ {
+ state: "revealed",
+ label: "Scrolled back up",
+ note: "The row returns already correct: the label and the track moved on to Hand over · Patient sheet while they were gone.",
+ },
+ {
+ state: "workspace",
+ label: "A workspace, not a record",
+ note: "On Compare there is no therapy to name, so line one names the workspace and its fill instead of borrowing a title that is not yours.",
+ },
+];
+
+export function TherapyNavigationContextMockups() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {PHONE_STATES.map((item) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/therapy-navigation-mockups/dock.tsx b/src/components/therapy-navigation-mockups/dock.tsx
new file mode 100644
index 0000000000..67d0767c59
--- /dev/null
+++ b/src/components/therapy-navigation-mockups/dock.tsx
@@ -0,0 +1,500 @@
+"use client";
+
+import { ChevronUp, Plus, Search, X } from "lucide-react";
+import { useId } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+import {
+ CATEGORY_COUNT,
+ ContentSkeleton,
+ CurrentDefects,
+ DeviceFrame,
+ MAX_COMPARE,
+ MockupSection,
+ MockupShell,
+ NoteCard,
+ PATHWAY_COUNT,
+ REVIEW_COUNT,
+ THERAPY_COUNT,
+ UniversalTopBar,
+ compareBasket,
+ destinationsIn,
+ focusRing,
+ selectedTherapy,
+ type TherapyFixture,
+} from "./shared";
+
+/* ------------------------------------------------------------------ *
+ * Direction C — Working-set dock.
+ *
+ * Navigation is a readout of what you are carrying. The library shrinks to
+ * a search-first strip, and the persistent surface is the working set: the
+ * record you have open, the compare basket with its four slots drawn, and
+ * the artifacts those two produce. You move by acting on what you hold.
+ * ------------------------------------------------------------------ */
+
+const LIBRARY = destinationsIn("library");
+const THERAPY_ACTIONS = destinationsIn("therapy");
+
+function SearchField({ compact = false }: { compact?: boolean }) {
+ return (
+
+ );
+}
+
+const PHONE_STATES: Array<{ state: PhoneState; label: string; note: string }> = [
+ {
+ state: "rest",
+ label: "At rest · one line",
+ note: "The dock is a sentence: this is the record you are holding, this is what you are doing to it, this is how full the basket is. That is less chrome than seven pills and strictly more information.",
+ },
+ {
+ state: "expanded",
+ label: "Pulled up",
+ note: "The whole working set: ACT with its artifacts, four basket slots with the fourth drawn empty, the review counter, and the library underneath because it is the least urgent thing here.",
+ },
+ {
+ state: "empty",
+ label: "Nothing held yet",
+ note: "With an empty working set the dock is just the search field. No greyed-out actions implying a broken feature, and no silent retarget to a therapy you never chose.",
+ },
+ {
+ state: "hidden",
+ label: "Scrolled — chrome gone",
+ note: "The dock hides with the universal header and releases its reserve to 0 rem, matching the phone chrome contract the rest of the app already keeps.",
+ },
+];
+
+export function TherapyNavigationDockMockups() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {PHONE_STATES.map((item) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/therapy-navigation-mockups/rail.tsx b/src/components/therapy-navigation-mockups/rail.tsx
new file mode 100644
index 0000000000..708e85c610
--- /dev/null
+++ b/src/components/therapy-navigation-mockups/rail.tsx
@@ -0,0 +1,506 @@
+"use client";
+
+import { ChevronRight, Columns3, Ellipsis, Layers, Search, type LucideIcon } from "lucide-react";
+import { useId } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+import {
+ CATEGORY_COUNT,
+ ContentSkeleton,
+ CurrentDefects,
+ DeviceFrame,
+ MAX_COMPARE,
+ MockupSection,
+ MockupShell,
+ NoteCard,
+ REVIEW_COUNT,
+ THERAPY_COUNT,
+ UniversalTopBar,
+ categories,
+ compareBasket,
+ destinationsIn,
+ focusRing,
+ groupCaptions,
+ groupLabels,
+ selectedTherapy,
+ type Destination,
+ type DestinationGroup,
+} from "./shared";
+
+/* ------------------------------------------------------------------ *
+ * Direction A — Grouped rail.
+ *
+ * The destinations are ranked spatially instead of being flattened into a
+ * pill row: Library (ways in), Your workspace (what you are carrying), and
+ * a third group that only exists while a therapy is selected and is named
+ * after it. Same model at every width; only the surface changes.
+ * ------------------------------------------------------------------ */
+
+const GROUP_ORDER: DestinationGroup[] = ["library", "workspace", "therapy"];
+
+/**
+ * The collapsed rail keeps counts as badges, but a badge is 16 px: a raw 205
+ * would blow the icon out, so anything past two digits is capped rather than
+ * dropped — the number still says "a lot", the rail still fits.
+ */
+function collapsedBadge(meta: string | undefined): string | null {
+ if (!meta) return null;
+ const leading = meta.split("/")[0];
+ if (!/^\d+$/.test(leading)) return null;
+ const value = Number(leading);
+ return value > 99 ? "99+" : leading;
+}
+
+function RailRow({
+ destination,
+ active,
+ collapsed = false,
+}: {
+ destination: Destination;
+ active: boolean;
+ collapsed?: boolean;
+}) {
+ const Icon = destination.icon;
+ const badge = collapsedBadge(destination.meta);
+
+ if (collapsed) {
+ return (
+
+ );
+}
+
+const PHONE_STATES: Array<{ state: PhoneState; label: string; note: string }> = [
+ {
+ state: "rest",
+ label: "At rest",
+ note: "Four fixed slots, no horizontal scroll, nothing off-screen. Compare wears its basket count so the workspace stops being invisible.",
+ },
+ {
+ state: "sheet",
+ label: "More — the full model",
+ note: "The same three groups as the desktop rail, in the same order, viewport-anchored. Everything the strip used to hide past the right edge is one tap away and legible.",
+ },
+ {
+ state: "hidden",
+ label: "Scrolled — chrome gone",
+ note: "The bar leaves with the universal header on a deliberate descent and releases its reserve to 0 rem. No phantom padding: the content paints to the physical edge.",
+ },
+ {
+ state: "empty",
+ label: "Nothing selected",
+ note: "The therapy group is omitted, not greyed. A dashed hint names what will appear and what it will act on, so Brief intervention can never silently open a different record's.",
+ },
+];
+
+export function TherapyNavigationRailMockups() {
+ return (
+
+
+
+
+
+ Therapy navigation · Direction {letter} · {name}
+
+
+ {headline}
+
+
{intro}
+
+
+ {children}
+
+
+ );
+}
+
+/* -- The shared critique, restated on every direction ------------------- */
+
+export const currentDefects: Array<{ title: string; body: string }> = [
+ {
+ title: "Three kinds of destination, one visual rank",
+ body: "Search and Recommend are doors into a 205-record library. Compare is a workspace holding a basket of up to four. Pathways is a browse structure over twelve workflows. Brief intervention and Patient sheet are artifacts of whichever therapy is selected. Seven identical pills say all seven are the same kind of thing.",
+ },
+ {
+ title: "It carries no state at all",
+ body: "Compare exists to hold up to four therapies and the strip never shows how many are in it. Every one of the 205 records is still `needs_review`, and Review is a real route reachable from the detail and pathway screens — with an active style already computed in bindings — that no button in the strip ever renders.",
+ },
+ {
+ title: "The therapy-scoped destinations retarget silently",
+ body: "Brief intervention and Patient sheet fall back to the first therapy in the catalogue that has one when the selected therapy does not. The strip never names whose brief you are about to open, so the swap is invisible.",
+ },
+ {
+ title: "On a phone it is a hidden horizontal scroller",
+ body: "Seven pills need roughly 700 px inside a 390 px viewport. Patient sheet sits off-screen with no scroll affordance, no edge fade, and nothing to say how far along the set you are.",
+ },
+ {
+ title: "On a desktop it wastes the width it demanded",
+ body: "A fit-content row centred in a full-width sticky glass bar leaves roughly a thousand pixels empty, while the sixteen categories and twelve pathways that would actually help someone move through 205 records get no surface at all.",
+ },
+ {
+ title: "Category is a first-class field with no way in",
+ body: "Every record is categorised and the catalogue splits cleanly into sixteen groups from 4 to 17 records. Navigation offers search or nothing.",
+ },
+];
+
+export function CurrentDefects() {
+ return (
+
+