From 341c3c9dd1c7a2e4dc3b0afc3f730243b6c67850 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:40:50 +0000 Subject: [PATCH 1/7] Add three phone mockups for the therapy comparison picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone compare screen opens with four empty A/B/C/D slot cards, a `Change therapies` button and an `Add therapies to compare` empty state — three affordances for one outcome, with the primary one pushed below the fold by ~1,100px of placeholders. The toolbar (density, Copy set, Clear) renders disabled above it all, and the payoff is a 720px-minimum table in a horizontal scroller. Three design-scratch directions, each a live phone prototype: - A `therapy-compare-progressive` — Add as you go. Empty slots are never drawn; one button and three starter pairs, then a row per therapy chosen. - B `therapy-compare-sheet` — Build the set in one sheet. Full-height multi-select with search, category chips and a draft that commits once. - C `therapy-compare-tray` — Carry a compare tray. Selection leaves the page body for a persistent bottom tray filled from search and records. All three share the same fixtures, a per-field stacked comparison in place of the sideways table, and the same six-defect brief. Mockup-only arbitrary Tailwind values are inlined: globals.css excludes `*mockup*` sources, so a utility used nowhere else in `src` is never emitted and silently collapses the frame (the shipped therapy-navigation mockups have the same issue). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WKxKU17WQqryM8iNhpyJk7 --- docs/site-map.md | 3 + src/app/mockups/mockups-layout-client.tsx | 7 + .../therapy-compare-progressive/page.tsx | 12 + .../mockups/therapy-compare-sheet/page.tsx | 13 + src/app/mockups/therapy-compare-tray/page.tsx | 13 + .../progressive.tsx | 391 ++++++++++++ .../therapy-compare-picker-mockups/shared.tsx | 581 ++++++++++++++++++ .../therapy-compare-picker-mockups/sheet.tsx | 472 ++++++++++++++ .../therapy-compare-picker-mockups/tray.tsx | 438 +++++++++++++ 9 files changed, 1930 insertions(+) create mode 100644 src/app/mockups/therapy-compare-progressive/page.tsx create mode 100644 src/app/mockups/therapy-compare-sheet/page.tsx create mode 100644 src/app/mockups/therapy-compare-tray/page.tsx create mode 100644 src/components/therapy-compare-picker-mockups/progressive.tsx create mode 100644 src/components/therapy-compare-picker-mockups/shared.tsx create mode 100644 src/components/therapy-compare-picker-mockups/sheet.tsx create mode 100644 src/components/therapy-compare-picker-mockups/tray.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 33f300dc51..06cfd15d8e 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1240,6 +1240,9 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. - `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. - `/mockups/sidebar-live` - Route discovered from app directory Source: `src/app/mockups/sidebar-live/page.tsx`. +- `/mockups/therapy-compare-progressive` - Route discovered from app directory Source: `src/app/mockups/therapy-compare-progressive/page.tsx`. +- `/mockups/therapy-compare-sheet` - Route discovered from app directory Source: `src/app/mockups/therapy-compare-sheet/page.tsx`. +- `/mockups/therapy-compare-tray` - Route discovered from app directory Source: `src/app/mockups/therapy-compare-tray/page.tsx`. - `/mockups/therapy-navigation-context` - Route discovered from app directory Source: `src/app/mockups/therapy-navigation-context/page.tsx`. - `/mockups/therapy-navigation-dock` - Route discovered from app directory Source: `src/app/mockups/therapy-navigation-dock/page.tsx`. - `/mockups/therapy-navigation-rail` - Route discovered from app directory Source: `src/app/mockups/therapy-navigation-rail/page.tsx`. diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index baeddb5b94..2457811ba7 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -74,6 +74,11 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { // These studies render their own top bar and composer inside each device // frame. Suppress shared chrome so it cannot be mistaken for the concept. const isTherapyNavigationMockup = pathname.startsWith("/mockups/therapy-navigation-"); + // These three draw a complete phone — universal header, page body and the + // edge-to-edge composer — inside every frame, because the selection surface + // under study is defined by what sits between those two. Shared chrome above + // them would read as a second real header and a second real search bar. + const isTherapyComparePickerMockup = pathname.startsWith("/mockups/therapy-compare-"); // The calculators search page owns its own search input (top on desktop, docked // at the bottom on phones), so the shared universal composer is suppressed here // to avoid a second, floating search bar. @@ -124,6 +129,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isServicesFilterOptionsMockup && !isFilterSheetRestyleMockup && !isTherapyNavigationMockup && + !isTherapyComparePickerMockup && !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && @@ -149,6 +155,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isFilterSheetRestyleMockup && !isPhoneInPageNavigationMockup && !isTherapyNavigationMockup && + !isTherapyComparePickerMockup && !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && diff --git a/src/app/mockups/therapy-compare-progressive/page.tsx b/src/app/mockups/therapy-compare-progressive/page.tsx new file mode 100644 index 0000000000..f689f60b67 --- /dev/null +++ b/src/app/mockups/therapy-compare-progressive/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { TherapyCompareProgressiveMockups } from "@/components/therapy-compare-picker-mockups/progressive"; + +export const metadata: Metadata = { + title: "Therapy comparison picker A · Add as you go - Clinical KB", + description: "Direction A for the phone therapy-comparison picker: Add as you go, shown as a live phone prototype.", +}; + +export default function TherapyCompareProgressiveMockupPage() { + return ; +} diff --git a/src/app/mockups/therapy-compare-sheet/page.tsx b/src/app/mockups/therapy-compare-sheet/page.tsx new file mode 100644 index 0000000000..fa3da8e87e --- /dev/null +++ b/src/app/mockups/therapy-compare-sheet/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { TherapyCompareSheetMockups } from "@/components/therapy-compare-picker-mockups/sheet"; + +export const metadata: Metadata = { + title: "Therapy comparison picker B · Build the set in one sheet - Clinical KB", + description: + "Direction B for the phone therapy-comparison picker: Build the set in one sheet, shown as a live phone prototype.", +}; + +export default function TherapyCompareSheetMockupPage() { + return ; +} diff --git a/src/app/mockups/therapy-compare-tray/page.tsx b/src/app/mockups/therapy-compare-tray/page.tsx new file mode 100644 index 0000000000..4d28d275b2 --- /dev/null +++ b/src/app/mockups/therapy-compare-tray/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { TherapyCompareTrayMockups } from "@/components/therapy-compare-picker-mockups/tray"; + +export const metadata: Metadata = { + title: "Therapy comparison picker C · Carry a compare tray - Clinical KB", + description: + "Direction C for the phone therapy-comparison picker: Carry a compare tray, shown as a live phone prototype.", +}; + +export default function TherapyCompareTrayMockupPage() { + return ; +} diff --git a/src/components/therapy-compare-picker-mockups/progressive.tsx b/src/components/therapy-compare-picker-mockups/progressive.tsx new file mode 100644 index 0000000000..56bcd1f570 --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/progressive.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { ArrowLeft, Copy, Plus, Search, Trash2, X } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { + AddedToast, + ComparisonPreview, + CurrentDefects, + MAX_COMPARE, + MockupSection, + MockupShell, + NoteCard, + PhoneComposer, + PhoneFrame, + PhoneTopBar, + ReviewPill, + SLOT_LETTERS, + SlotBadge, + THERAPY_COUNT, + bySlug, + filterTherapies, + focusRing, + useCompareSet, +} from "./shared"; + +/* ------------------------------------------------------------------ * + * Direction A — Add as you go. + * + * The screen shows what you have chosen and one way to add the next one. + * Zero selections means zero cards: a single primary button and two starter + * pairs. The slot letters still exist — they are what the comparison keys + * off — but they are minted as you fill them, never drawn empty. + * ------------------------------------------------------------------ */ + +const STARTERS: Array<{ label: string; slugs: [string, string] }> = [ + { label: "CBT vs ACT", slugs: ["cognitive-behavioural-therapy-cbt", "acceptance-and-commitment-therapy-act"] }, + { label: "DBT vs MBT", slugs: ["dialectical-behaviour-therapy-dbt", "mentalisation-based-treatment-mbt"] }, + { + label: "EMDR vs CPT", + slugs: ["eye-movement-desensitisation-and-reprocessing-emdr", "cognitive-processing-therapy-cpt"], + }, +]; + +function ProgressivePhone() { + const set = useCompareSet(); + const [picking, setPicking] = useState(false); + const [query, setQuery] = useState(""); + const [toast, setToast] = useState(null); + const results = filterTherapies(query, "All"); + const count = set.selected.length; + + function choose(slug: string) { + set.add(slug); + setPicking(false); + setQuery(""); + const therapy = bySlug(slug); + setToast(therapy ? `${therapy.short} added as ${SLOT_LETTERS[count] ?? "next"}` : null); + window.setTimeout(() => setToast(null), 1600); + } + + return ( + + + +
+ {/* Title block: one line, and a count that only exists once it counts. */} +
+
+

Compare therapies

+

+ {count === 0 + ? "Add two or more to see fit, cautions and delivery side by side." + : count === 1 + ? "Add one more to start the comparison." + : `${count} of ${MAX_COMPARE} · fit, cautions, delivery and evidence.`} +

+
+ {count > 0 ? ( + + ) : null} +
+ + {/* Chosen therapies — one row each, nothing more. */} + {count > 0 ? ( +
    + {set.items.map((therapy, index) => ( +
  • + +
    +

    {therapy.name}

    +

    + {therapy.category} +

    +
    + +
    +
    + +
  • + ))} +
+ ) : null} + + {/* The single add affordance. Primary while empty, quiet once filling. */} + {set.full ? ( +

+ {MAX_COMPARE} of {MAX_COMPARE} — remove one to swap in another +

+ ) : ( + + )} + + {/* Starter pairs — only while they can still fit. */} + {count === 0 ? ( +
+

+ Or start from a pair +

+
+ {STARTERS.map((starter) => ( + + ))} +
+

+ You can also add from a search result or from any therapy record — the set follows you. +

+
+ ) : null} + + {/* Everything that only makes sense with a comparison on screen. */} + {count >= 2 ? ( + <> + +
+ +
+ + ) : null} +
+ + + + + {/* Full-screen picker. One tap adds and returns. */} + {picking ? ( +
+
+ +

+ Add therapy {SLOT_LETTERS[set.selected.length] ?? ""} +

+
+
+
+
+
+
    + {results.map((therapy) => { + const already = set.selected.includes(therapy.slug); + return ( +
  • + +
  • + ); + })} + {results.length === 0 ? ( +
  • No matching therapies.
  • + ) : null} +
+
+ ) : null} +
+ ); +} + +function StatePhone({ label, note, slugs }: { label: string; note: string; slugs: string[] }) { + const items = slugs.map((slug) => bySlug(slug)).filter((entry): entry is NonNullable => Boolean(entry)); + return ( + + +
+

Compare therapies

+

+ {items.length} of {MAX_COMPARE} · fit, cautions, delivery and evidence. +

+
    + {items.map((therapy, index) => ( +
  • + +
    +

    {therapy.name}

    +

    + {therapy.category} +

    +
    +
  • + ))} +
+

+ {MAX_COMPARE} of {MAX_COMPARE} — remove one to swap in another +

+
+ +
+
+ +
+ ); +} + +export function TherapyCompareProgressiveMockups() { + return ( + + + + +
+ + +
+
+ + +
+ + + + + + +
+
+ + +
+ + + +
+
+
+ ); +} diff --git a/src/components/therapy-compare-picker-mockups/shared.tsx b/src/components/therapy-compare-picker-mockups/shared.tsx new file mode 100644 index 0000000000..c84ccf5f36 --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/shared.tsx @@ -0,0 +1,581 @@ +"use client"; + +import { Check, Clock, Scale, Search, ShieldAlert, Target, TriangleAlert, type LucideIcon } from "lucide-react"; +import Link from "next/link"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; + +import { cn } from "@/components/ui-primitives"; + +/* ------------------------------------------------------------------ * + * Shared vocabulary for the three Therapy-comparison picker directions. + * Design-scratch only: this directory 404s in production. + * + * The subject is the phone selection experience on /therapy-compass/compare, + * not the comparison table itself. Every direction below answers the same + * six defects listed in `currentDefects`. + * ------------------------------------------------------------------ */ + +export type Device = "phone"; + +export const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +/** `THERAPY_MAX_COMPARE` in `src/lib/therapy-compass-navigation.ts`. */ +export const MAX_COMPARE = 4; +/** `src/data/therapies-index.json` — 205 records across 16 categories. */ +export const THERAPY_COUNT = 205; +export const CATEGORY_COUNT = 16; + +export type TherapyFixture = { + slug: string; + name: string; + short: string; + category: string; + /** Placeholder comparison copy — structural, never a clinical claim. */ + fit: string; + time: string; + complexity: string; + caution: string; +}; + +/** + * Ten real catalogue records with placeholder comparison copy. Every record in + * the shipped catalogue is `needs_review`, so every card here says so too. + */ +export const THERAPIES: TherapyFixture[] = [ + { + slug: "cognitive-behavioural-therapy-cbt", + name: "Cognitive Behavioural Therapy (CBT)", + short: "CBT", + category: "Standard Talking Therapies", + fit: "Structured, present-focused work across depression and the anxiety spectrum.", + time: "12–20 sessions · 50 min", + complexity: "Trained therapist", + caution: "Source review required — open the record.", + }, + { + slug: "acceptance-and-commitment-therapy-act", + name: "Acceptance and Commitment Therapy (ACT)", + short: "ACT", + category: "Standard Talking Therapies", + fit: "Avoidance and fusion prominent; values-led behaviour change.", + time: "8–16 sessions · 50 min", + complexity: "Trained therapist", + caution: "Source review required — open the record.", + }, + { + slug: "behavioural-activation-ba", + name: "Behavioural Activation (BA)", + short: "BA", + category: "Standard Talking Therapies", + fit: "Depression with marked withdrawal and loss of routine.", + time: "8–12 sessions · 30–50 min", + complexity: "Can be delivered by trained non-specialists", + caution: "Source review required — open the record.", + }, + { + slug: "dialectical-behaviour-therapy-dbt", + name: "Dialectical Behaviour Therapy (DBT)", + short: "DBT", + category: "Personality Disorder Therapies", + fit: "Recurrent self-harm and emotion dysregulation; programme-based.", + time: "6–12 months · programme", + complexity: "Full team and supervision", + caution: "Source review required — open the record.", + }, + { + slug: "mentalisation-based-treatment-mbt", + name: "Mentalisation-Based Treatment (MBT)", + short: "MBT", + category: "Personality Disorder Therapies", + fit: "Interpersonal instability where mentalising collapses under arousal.", + time: "12–18 months · programme", + complexity: "Full team and supervision", + caution: "Source review required — open the record.", + }, + { + slug: "eye-movement-desensitisation-and-reprocessing-emdr", + name: "Eye Movement Desensitisation and Reprocessing (EMDR)", + short: "EMDR", + category: "Trauma Therapies", + fit: "Single-incident and complex trauma presentations.", + time: "8–12 sessions · 60–90 min", + complexity: "Accredited training required", + caution: "Source review required — open the record.", + }, + { + slug: "cognitive-processing-therapy-cpt", + name: "Cognitive Processing Therapy (CPT)", + short: "CPT", + category: "Trauma Therapies", + fit: "PTSD with prominent stuck points and appraisal distortion.", + time: "12 sessions · 60 min", + complexity: "Accredited training required", + caution: "Source review required — open the record.", + }, + { + slug: "interpersonal-psychotherapy-ipt", + name: "Interpersonal Psychotherapy (IPT)", + short: "IPT", + category: "Standard Talking Therapies", + fit: "Depression framed around role transition, grief or dispute.", + time: "12–16 sessions · 50 min", + complexity: "Trained therapist", + caution: "Source review required — open the record.", + }, + { + slug: "exposure-and-response-prevention-erp", + name: "Exposure and Response Prevention (ERP)", + short: "ERP", + category: "OCD & Exposure Therapies", + fit: "OCD with identifiable compulsions and avoidance hierarchy.", + time: "12–20 sessions · 60–90 min", + complexity: "Trained therapist", + caution: "Source review required — open the record.", + }, + { + slug: "motivational-interviewing-mi", + name: "Motivational Interviewing (MI)", + short: "MI", + category: "Substance Use Therapies", + fit: "Ambivalence about change; often a front end to other therapy.", + time: "1–4 sessions · 20–50 min", + complexity: "Can be delivered by trained non-specialists", + caution: "Source review required — open the record.", + }, +]; + +export const CATEGORY_FILTERS = [ + "All", + "Standard Talking Therapies", + "Trauma Therapies", + "Personality Disorder Therapies", + "OCD & Exposure Therapies", + "Substance Use Therapies", +]; + +export const SLOT_LETTERS = ["A", "B", "C", "D"]; + +export function bySlug(slug: string): TherapyFixture | undefined { + return THERAPIES.find((therapy) => therapy.slug === slug); +} + +/* -- Shared selection state -------------------------------------------- */ + +export function useCompareSet(initial: string[] = []) { + const [selected, setSelected] = useState(initial); + + const add = useCallback((slug: string) => { + setSelected((current) => (current.includes(slug) || current.length >= MAX_COMPARE ? current : [...current, slug])); + }, []); + + const remove = useCallback((slug: string) => { + setSelected((current) => current.filter((entry) => entry !== slug)); + }, []); + + const toggle = useCallback((slug: string) => { + setSelected((current) => { + if (current.includes(slug)) return current.filter((entry) => entry !== slug); + return current.length >= MAX_COMPARE ? current : [...current, slug]; + }); + }, []); + + const clear = useCallback(() => setSelected([]), []); + const reset = useCallback(() => setSelected(initial), [initial]); + + const items = useMemo( + () => selected.map((slug) => bySlug(slug)).filter((entry): entry is TherapyFixture => Boolean(entry)), + [selected], + ); + + return { selected, items, add, remove, toggle, clear, reset, setSelected, full: selected.length >= MAX_COMPARE }; +} + +/* -- Search ------------------------------------------------------------- */ + +export function filterTherapies(query: string, category: string): TherapyFixture[] { + const needle = query.trim().toLowerCase(); + return THERAPIES.filter((therapy) => { + if (category !== "All" && therapy.category !== category) return false; + if (!needle) return true; + return ( + therapy.name.toLowerCase().includes(needle) || + therapy.short.toLowerCase().includes(needle) || + therapy.category.toLowerCase().includes(needle) + ); + }); +} + +/* -- Phone chrome ------------------------------------------------------- */ + +/** The universal phone header the compare screen sits under, drawn to scale. */ +export function PhoneTopBar({ title = "Therapy" }: { title?: string }) { + return ( +
+ + KB + + + +
+ ); +} + +/** The edge-to-edge phone search composer every mode page has to live above. */ +export function PhoneComposer() { + return ( +
+
+
+
+ ); +} + +/** A phone viewport at true proportion (390 × 780), with its state called out. */ +export function PhoneFrame({ + label, + note, + children, + tall = false, +}: { + label: string; + note?: string; + children: ReactNode; + tall?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {children} +
+ {note ?

{note}

: null} +
+ ); +} + +/* -- Reusable pieces every direction shares ----------------------------- */ + +export function ReviewPill() { + return ( + + + ); +} + +export function SlotBadge({ index, muted = false }: { index: number; muted?: boolean }) { + return ( + + {SLOT_LETTERS[index] ?? index + 1} + + ); +} + +/** + * The payoff, drawn the same way in all three directions: a per-field stack, + * not the shipped 720 px-minimum table that a phone can only scroll sideways. + */ +const PREVIEW_ROWS: Array<{ + key: string; + label: string; + icon: LucideIcon; + warn?: boolean; + get: (t: TherapyFixture) => string; +}> = [ + { key: "caution", label: "When not to use", icon: TriangleAlert, warn: true, get: (t) => t.caution }, + { key: "fit", label: "Best fit", icon: Target, get: (t) => t.fit }, + { key: "time", label: "Time required", icon: Clock, get: (t) => t.time }, + { key: "complexity", label: "Clinician skill", icon: Scale, get: (t) => t.complexity }, +]; + +export function ComparisonPreview({ items }: { items: readonly TherapyFixture[] }) { + if (items.length < 2) return null; + return ( +
+
+

+ {items.map((item) => item.short).join(" vs ")} +

+ {items.length} compared +
+ {PREVIEW_ROWS.map((row) => ( +
+

+

+
+ {items.map((item, index) => ( +
+
+ + {item.short} +
+
+ {row.get(item)} +
+
+ ))} +
+
+ ))} +
+ ); +} + +export function AddedToast({ label }: { label: string | null }) { + if (!label) return null; + return ( +
+ + +
+ ); +} + +/* -- Page furniture ----------------------------------------------------- */ + +export function NoteCard({ + title, + body, + tone = "plain", +}: { + title: string; + body: string; + tone?: "plain" | "accent" | "warn"; +}) { + return ( +
+

{title}

+

{body}

+
+ ); +} + +export function MockupSection({ + id, + title, + intro, + children, + divider = true, +}: { + id: string; + title: string; + intro?: string; + children: ReactNode; + divider?: boolean; +}) { + return ( +
+

+ {title} +

+ {intro ?

{intro}

: null} +
{children}
+
+ ); +} + +export const directions = [ + { + slug: "therapy-compare-progressive", + letter: "A", + name: "Add as you go", + tagline: "No empty slots. One button, then a row per therapy you actually chose.", + }, + { + slug: "therapy-compare-sheet", + letter: "B", + name: "Build the set in one sheet", + tagline: "Pick everything in a single full-screen pass, then land on the comparison.", + }, + { + slug: "therapy-compare-tray", + letter: "C", + name: "Carry a compare tray", + tagline: "Selection lives in a bottom tray you fill from anywhere, never in the page.", + }, +]; + +function DirectionSwitcher({ current }: { current: string }) { + return ( + + ); +} + +export function MockupShell({ + current, + letter, + name, + headline, + intro, + children, +}: { + current: string; + letter: string; + name: string; + headline: string; + intro: string; + children: ReactNode; +}) { + return ( +
+
+
+

+ Therapy comparison · phone picker · Direction {letter} · {name} +

+

+ {headline} +

+

{intro}

+

+ The phone below is live — tap it. Comparison copy is placeholder text for layout only, not clinical content; + every record in the real catalogue is still needs review, which is why each card says so. +

+
+ + {children} +
+
+ ); +} + +export const currentDefects: Array<{ title: string; body: string }> = [ + { + title: "Four empty slots before a single choice", + body: "The screen opens with A, B, C and D already drawn as cards. Nothing is selected, so all four are dead placeholders that push the only real action — the one that adds a therapy — below the fold. Four is the ceiling, not the plan.", + }, + { + title: "Three controls that all do the same thing", + body: "Each empty slot card is tappable, `Change therapies` sits under them, and `Add therapies to compare` sits under that. Three affordances, one outcome, and the biggest and most prominent of them is the one you reach last.", + }, + { + title: "The toolbar is disabled at the moment you see it", + body: "Comfortable/Dense, Copy set and Clear all render at full strength above the fold with nothing to act on. Density in particular decides how a wide table breathes — a decision that has no meaning until there is a table, and none at all on a 390 px screen.", + }, + { + title: "The count is chrome, not feedback", + body: "`0 of 4 selected` is a static badge in the header, detached from the slots it counts, and in the captured screen it sits partly under the page's own edge control.", + }, + { + title: "The slot cards do not fill the phone", + body: "The strip is `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`, but on the phone the cards still stop short of the right edge while the buttons beneath them run full width — so the column looks broken rather than deliberate.", + }, + { + title: "The payoff is a sideways-scrolling table", + body: "Once two are chosen, the comparison is a `min-w-[720px]` table inside a horizontal scroller. On a 390 px viewport that is two thirds of a column at a time, with the field labels scrolling away from the values they label.", + }, +]; + +export function CurrentDefects() { + return ( + +
+ {currentDefects.map((defect) => ( + + ))} +
+
+ ); +} diff --git a/src/components/therapy-compare-picker-mockups/sheet.tsx b/src/components/therapy-compare-picker-mockups/sheet.tsx new file mode 100644 index 0000000000..df687e6769 --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/sheet.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { Check, Copy, Layers, Pencil, Plus, Scale, Search, X } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { + CATEGORY_FILTERS, + ComparisonPreview, + CurrentDefects, + MAX_COMPARE, + MockupSection, + MockupShell, + NoteCard, + PhoneComposer, + PhoneFrame, + PhoneTopBar, + SLOT_LETTERS, + THERAPY_COUNT, + bySlug, + filterTherapies, + focusRing, + useCompareSet, +} from "./shared"; + +/* ------------------------------------------------------------------ * + * Direction B — Build the set in one sheet. + * + * The page body never holds a picker. Choosing is one full-height sheet: + * search, category filter, a multi-select list, and a docked bar that + * counts what you have and commits the whole set in one action. You return + * to the comparison, not to another empty slot. + * ------------------------------------------------------------------ */ + +function SheetPhone() { + const set = useCompareSet(); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState([]); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("All"); + const results = filterTherapies(query, category); + + function openSheet() { + setDraft(set.selected); + setQuery(""); + setCategory("All"); + setOpen(true); + } + + function toggleDraft(slug: string) { + setDraft((current) => { + if (current.includes(slug)) return current.filter((entry) => entry !== slug); + return current.length >= MAX_COMPARE ? current : [...current, slug]; + }); + } + + function commit() { + set.setSelected(draft); + setOpen(false); + } + + const count = set.selected.length; + const draftItems = draft + .map((slug) => bySlug(slug)) + .filter((entry): entry is NonNullable => Boolean(entry)); + + return ( + + + +
+

Compare therapies

+ + {count === 0 ? ( + <> +

+ Choose two to four therapies and see fit, cautions, delivery and evidence together. +

+ +
+

+

+
    +
  • · Cautions first, then best fit, time and clinician skill
  • +
  • · Every field links back to the source record
  • +
  • · Copy the whole set as text for a letter or note
  • +
+
+ + ) : ( + <> + {/* The whole selection as one wrapping line of pills. */} +
+ {set.items.map((therapy, index) => ( + + + {SLOT_LETTERS[index]} + + {therapy.short} + + + ))} + +
+ + {count === 1 ? ( +

+ One more needed before anything can be compared. +

+ ) : ( + <> + +
+ +
+ + )} + + )} +
+ + + + {/* The full-height sheet: the only place selection happens. */} + {open ? ( +
+
+
+

Choose therapies

+

+ Up to {MAX_COMPARE} · tick as many as you need +

+
+ +
+ +
+
+
+
+ + {/* Category is a first-class field in the data; give it a way in. */} +
+
+ {CATEGORY_FILTERS.map((entry) => ( + + ))} +
+
+ +
    + {results.map((therapy) => { + const ticked = draft.includes(therapy.slug); + const blocked = !ticked && draft.length >= MAX_COMPARE; + return ( +
  • + +
  • + ); + })} + {results.length === 0 ? ( +
  • + No therapies match that search in this category. +
  • + ) : null} +
+ + {/* Docked commit bar: what you have, and the one way out. */} +
+
+ {draftItems.length === 0 ? ( + + Nothing ticked yet — choose at least two. + + ) : ( + draftItems.map((therapy) => ( + + {therapy.short} + + )) + )} +
+ +
+
+ ) : null} +
+ ); +} + +function SheetStatePhone() { + return ( + +
+
+
+

Choose therapies

+

Up to 4 · tick as many as you need

+
+
+
+
+
+
+
+
+ + Trauma + + + All 205 + +
+
+
    + {["eye-movement-desensitisation-and-reprocessing-emdr", "cognitive-processing-therapy-cpt"].map((slug) => { + const therapy = bySlug(slug); + if (!therapy) return null; + return ( +
  • + + + + + {therapy.name} + + + {therapy.category} + + +
  • + ); + })} +
+
+
+ + EMDR + + + CPT + +
+ + +
+
+
+ ); +} + +export function TherapyCompareSheetMockups() { + return ( + + + + +
+ + +
+
+ + +
+ + + + + + +
+
+ + +
+ + + +
+
+
+ ); +} diff --git a/src/components/therapy-compare-picker-mockups/tray.tsx b/src/components/therapy-compare-picker-mockups/tray.tsx new file mode 100644 index 0000000000..e33262d4ec --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/tray.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { ArrowLeft, Check, ChevronUp, Copy, Plus, Scale, Search, Trash2, X } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { + ComparisonPreview, + CurrentDefects, + MAX_COMPARE, + MockupSection, + MockupShell, + NoteCard, + PhoneFrame, + PhoneTopBar, + ReviewPill, + SLOT_LETTERS, + SlotBadge, + THERAPY_COUNT, + bySlug, + filterTherapies, + focusRing, + useCompareSet, +} from "./shared"; + +/* ------------------------------------------------------------------ * + * Direction C — Carry a compare tray. + * + * Selection stops being page content. A tray sits at the bottom of every + * Therapy screen showing what you are carrying; you fill it from search + * results and therapy records without ever visiting a compare screen with + * nothing in it. The compare page is then only ever the comparison. + * ------------------------------------------------------------------ */ + +const BROWSE_RESULTS = [ + "cognitive-behavioural-therapy-cbt", + "acceptance-and-commitment-therapy-act", + "behavioural-activation-ba", + "interpersonal-psychotherapy-ipt", + "dialectical-behaviour-therapy-dbt", + "exposure-and-response-prevention-erp", +]; + +function TrayPhone() { + const set = useCompareSet(); + const [expanded, setExpanded] = useState(false); + const [view, setView] = useState<"browse" | "compare">("browse"); + const [query, setQuery] = useState(""); + const count = set.selected.length; + const results = filterTherapies(query, "All").filter((therapy) => + query.trim() ? true : BROWSE_RESULTS.includes(therapy.slug), + ); + + return ( + + + + {view === "browse" ? ( + <> +
+
+
+
+
    + {results.map((therapy) => { + const held = set.selected.includes(therapy.slug); + return ( +
  • +
    +

    {therapy.name}

    +

    + {therapy.category} +

    +
    + +
  • + ); + })} +
+ + ) : ( +
+ +

+ {set.items.map((item) => item.short).join(" vs ")} +

+ +
+ +
+
+ )} + + {/* The tray. Present on every Therapy screen, above the composer. */} + {expanded ? ( + + +
    + {set.items.map((therapy, index) => ( +
  • + +
    +

    {therapy.name}

    +
    + +
    +
    + +
  • + ))} + {count === 0 ? ( +
  • + Nothing in the tray yet. Add from any therapy in the list above. +
  • + ) : null} +
+ + ) : null} + + {/* The collapsed bar. Always visible; the whole bar is the toggle. */} +
+ + +
+ + +
+ ); +} + +function TrayEmptyPhone() { + return ( + + +
+
+
+
    + {BROWSE_RESULTS.slice(0, 5).map((slug) => { + const therapy = bySlug(slug); + if (!therapy) return null; + return ( +
  • + + + {therapy.name} + + + {therapy.category} + + + + +
  • + ); + })} +
+
+
+
+ + {Array.from({ length: 4 }, (_, index) => ( + + · + + ))} + + + Compare tray empty + 0 of 4 selected + + + +
+
+
+ ); +} + +export function TherapyCompareTrayMockups() { + return ( + + + + +
+ + +
+
+ + +
+ + + + + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+ ); +} From 767f38814947eacb53ba8097ffb95bc998a4776b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 10:49:13 +0000 Subject: [PATCH 2/7] fix(mockups): keep Direction C tray compare reachable and honest Leave compare when Empty or a row remove drops the tray below two, and dim leftover Plus controls at the four-therapy ceiling so they no-op visibly instead of looking live. --- .../therapy-compare-picker-mockups/tray.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/therapy-compare-picker-mockups/tray.tsx b/src/components/therapy-compare-picker-mockups/tray.tsx index e33262d4ec..181ccf56d2 100644 --- a/src/components/therapy-compare-picker-mockups/tray.tsx +++ b/src/components/therapy-compare-picker-mockups/tray.tsx @@ -77,6 +77,7 @@ function TrayPhone() {
    {results.map((therapy) => { const held = set.selected.includes(therapy.slug); + const blocked = !held && set.full; return (
  • +

    + {set.items.map((item) => item.short).join(" vs ")} +

    + +
    + +
    + + ) : record ? ( + /* ---------------- A therapy record ---------------- */ +
    + +

    + {record.name} +

    +

    {record.category}

    +
    + +
    + + {/* The point of the whole direction: add without leaving what you + are reading. */} + + +
    + + + + +
    +

    +

    +
    + ) : ( + /* ---------------- The therapy list ---------------- */ + <> +
    +
    +
    +
    +
      + {filterTherapies(query, "All").map((therapy) => { + const held = set.selected.includes(therapy.slug); + const blocked = !held && set.full; + return ( +
    • + + +
    • + ); + })} +
    + + )} + + {/* Scrim, only while the tray is open as a sheet. */} + {expanded ? ( + + +
      + {set.items.map((therapy, index) => ( +
    • + +
      +

      {therapy.name}

      +
      + +
      +
      + +
    • + ))} +
    + {count === 1 ? ( +

    + Add one more to compare. Up to {MAX_COMPARE}. +

    + ) : null} + + ) : null} + + {/* The collapsed bar. Only exists once something is in it. */} + {hasTray ? ( +
    + + +
    + ) : null} + + {/* The universal composer, unchanged and still the bottom-most owner. */} +
    +
    +
    +
    + + + ); +} + +function RecordBlock({ label, body, warn }: { label: string; body: string; warn?: boolean }) { + return ( +
    +

    + {label} +

    +

    + {body} +

    +
    + ); +} + +const RESTING_ROWS = [ + "cognitive-behavioural-therapy-cbt", + "acceptance-and-commitment-therapy-act", + "behavioural-activation-ba", + "interpersonal-psychotherapy-ipt", + "dialectical-behaviour-therapy-dbt", + "exposure-and-response-prevention-erp", +]; + +/** The zero state, drawn on its own so the cost of the idea is visible: none. */ +function RestingPhone() { + return ( + + +
    +
    +
    +
      + {RESTING_ROWS.map((slug) => { + const therapy = bySlug(slug); + if (!therapy) return null; + return ( +
    • + + + {therapy.name} + + + {therapy.category} + + + + +
    • + ); + })} +
    +
    +
    +
    +
    +
    +
    + ); +} + +export function TherapyComparePerfectedMockups() { + return ( + + +
    + + +
    +
    + + +
    + + + + + + +
    +
    + + +
    + + + + + + +
    +
    + + +
    + ); +} From 2f4bee236a9126e66cd244013dee2f354a2ee273 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 04:01:04 +0000 Subject: [PATCH 6/7] Regenerate the repo-awareness snapshot for the four mockup routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the Static PR checks failure on 022021d: [repo-awareness] The committed snapshot is behind the repository: - routes differs from the repository `data/repo-awareness-snapshot.json` records every route in the repository and the four `/mockups/therapy-compare-*` pages were missing from it. The regenerated snapshot adds exactly those four and nothing else. Why it passed locally and failed in CI: the branch was 12 commits behind main, and `snapshot:repo-awareness` / `check:repo-awareness-snapshot` were added to package.json on main after this branch was cut. CI evaluates the merge of the head with the current base, so it ran a gate whose npm script this branch did not yet contain — `npm run snapshot:repo-awareness` failed with "Missing script" here while the same check ran and failed there. The gate is also outside the `verify:cheap` set, so a clean local broad run said nothing about it. Merging main in resolves both halves. Verified on the merged tree: check:repo-awareness-snapshot in step (192 pages), sitemap:check clean, lint and typecheck green, and the three tests that guard the snapshot and route reachability passing (22 tests). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WKxKU17WQqryM8iNhpyJk7 --- data/repo-awareness-snapshot.json | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index bb0fbda77f..83f5e5c8f2 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "e265a187027a9c566734d73e50f68c4f2cd1af48", - "committed_at": "2026-08-25T03:42:15+00:00" + "sha": "87225fbb3f2430559946d04f4b62d3766929a93c", + "committed_at": "2026-08-25T03:57:12+00:00" }, "routes": { "modes": [ @@ -788,6 +788,26 @@ "file": "src/app/mockups/sidebar-live/page.tsx", "area": "mockup" }, + { + "path": "/mockups/therapy-compare-perfected", + "file": "src/app/mockups/therapy-compare-perfected/page.tsx", + "area": "mockup" + }, + { + "path": "/mockups/therapy-compare-progressive", + "file": "src/app/mockups/therapy-compare-progressive/page.tsx", + "area": "mockup" + }, + { + "path": "/mockups/therapy-compare-sheet", + "file": "src/app/mockups/therapy-compare-sheet/page.tsx", + "area": "mockup" + }, + { + "path": "/mockups/therapy-compare-tray", + "file": "src/app/mockups/therapy-compare-tray/page.tsx", + "area": "mockup" + }, { "path": "/mockups/therapy-navigation-context", "file": "src/app/mockups/therapy-navigation-context/page.tsx", @@ -1354,9 +1374,9 @@ ], "counts": { "modes": 15, - "pages": 188, + "pages": 192, "product_pages": 67, - "mockup_pages": 121, + "mockup_pages": 125, "redirects": 17, "api": 56 } From f11e7cd5b49a5898a2a3a3ca7d3ab97f4fda3b75 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:50:56 +0000 Subject: [PATCH 7/7] Refresh the bundle budget baselines; the mockups scratch ceiling was exhausted Build failed on 6d12681: mockups scratch 620.0 KiB, +27.2% against a 25% tolerance. Measured both sides before touching the baseline rather than refreshing reflexively: origin/main alone 594.3 KiB +21.9% (already past the 15% drift warning) main + these 4 routes 620.0 KiB +27.2% FAIL So 21.9 of the 27.2 points predate this branch. Four mockup routes add 25.7 KiB and 5.3 points, which is what tips it over - the failure is this PR's to fix, but the accumulated drift is the dominant cause. This is the failure mode recorded as `#QSHHGK`: nothing schedules a baseline refresh, so accumulated growth fails whichever PR lands last. Refreshed with `check:bundle-budget -- --update`, which the failing check itself prescribes for this bucket - it is a repo-hygiene ceiling on unbounded accumulation, not a per-mockup gate, and nobody downloads these chunks. Every production-facing baseline moved DOWN, i.e. tighter, not looser: production 1656.0 -> 1655.1 KiB route / 278.5 -> 233.0 KiB route /documents/... 281.6 -> 236.1 KiB mockups (scratch) 487.6 -> 620.0 KiB Only the scratch bucket rose. Verified no route budget was dropped by the update (both before and after track `/` and `/documents/search`). The old baselineSource 0764fb581356 was 71 commits stale and not an ancestor of HEAD, which is why the check also warned about ancestry; it now points at 6d1268197017. Measured on a clean build (`rm -rf .next` first, per the AGENTS.md warning that a cached .next makes this check report stale byte-identical numbers). --- bundle-budget.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bundle-budget.json b/bundle-budget.json index 29d9de2641..9c9eb82cbf 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -2,25 +2,25 @@ "$comment": "Client JS bundle-size budgets captured from a known-good production build. `production` guards aggregate user-facing chunks, `routes` guards the five Lighthouse journeys against route-local growth, and `mockups` is a looser design-scratch hygiene ceiling. Refresh intentionally with `npm run check:bundle-budget -- --update`.", "enforce": true, "production": { - "gzipBytes": 1695752, + "gzipBytes": 1694829, "tolerancePct": 10 }, "mockups": { - "gzipBytes": 499284, + "gzipBytes": 634923, "tolerancePct": 25 }, "routes": { "/": { - "gzipBytes": 285184, + "gzipBytes": 238556, "tolerancePct": 10 }, "/documents/search": { - "gzipBytes": 288358, + "gzipBytes": 241755, "tolerancePct": 10 } }, - "totalGzipBytes": 2195036, + "totalGzipBytes": 2329752, "tolerancePct": 10, - "updatedAt": "2026-08-23T10:15:00.000Z", - "baselineSource": "0764fb5813564cc1cb8933267597478ecff9c354" + "updatedAt": "2026-08-25T21:50:25.445Z", + "baselineSource": "6d12681970171a8572ef1dd9aa5f5dcdaf074969" }