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" } diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index e040b9c31e..cd3f904129 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": "dc68600786530b60c732aefe050a02e742eb2421", - "committed_at": "2026-08-26T05:55:49+08:00" + "sha": "6d12681970171a8572ef1dd9aa5f5dcdaf074969", + "committed_at": "2026-08-25T21:32:15+00:00" }, "routes": { "modes": [ @@ -793,6 +793,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", @@ -1374,9 +1394,9 @@ ], "counts": { "modes": 15, - "pages": 192, + "pages": 196, "product_pages": 55, - "mockup_pages": 137, + "mockup_pages": 141, "redirects": 17, "api": 56 } diff --git a/docs/site-map.md b/docs/site-map.md index bb3153476a..2f2e76f2a2 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1237,6 +1237,10 @@ 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-perfected` - Route discovered from app directory Source: `src/app/mockups/therapy-compare-perfected/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 6149ceb96e..54aa4050c8 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -84,6 +84,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. @@ -171,6 +176,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isServicesFilterOptionsMockup && !isFilterSheetRestyleMockup && !isTherapyNavigationMockup && + !isTherapyComparePickerMockup && !isWarningConsolidationMockup && !isWarningLineMockup && !isAlsoMatchesAccentMockup && @@ -202,6 +208,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isFilterSheetRestyleMockup && !isPhoneInPageNavigationMockup && !isTherapyNavigationMockup && + !isTherapyComparePickerMockup && !isWarningConsolidationMockup && !isWarningLineMockup && !isAlsoMatchesAccentMockup && diff --git a/src/app/mockups/therapy-compare-perfected/page.tsx b/src/app/mockups/therapy-compare-perfected/page.tsx new file mode 100644 index 0000000000..1a85624414 --- /dev/null +++ b/src/app/mockups/therapy-compare-perfected/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { TherapyComparePerfectedMockups } from "@/components/therapy-compare-picker-mockups/tray-perfected"; + +export const metadata: Metadata = { + title: "Therapy comparison picker C+ · Perfected tray - Clinical KB", + description: + "The perfected compare-tray direction: no tray until a set exists, one bottom stack that hides with the composer, and adding from a therapy record.", +}; + +export default function TherapyComparePerfectedMockupPage() { + return ; +} 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..ccede720dc --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/progressive.tsx @@ -0,0 +1,392 @@ +"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..a53481dbdf --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/shared.tsx @@ -0,0 +1,677 @@ +"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.", + }, + { + slug: "mindfulness-based-cognitive-therapy-mbct", + name: "Mindfulness-Based Cognitive Therapy (MBCT)", + short: "MBCT", + category: "Standard Talking Therapies", + fit: "Recurrent depression in remission; relapse prevention.", + time: "8 weekly sessions · group", + complexity: "Trained therapist", + caution: "Source review required — open the record.", + }, + { + slug: "schema-therapy", + name: "Schema Therapy", + short: "Schema", + category: "Personality Disorder Therapies", + fit: "Longstanding relational patterns unresponsive to shorter work.", + time: "12–24 months", + complexity: "Accredited training and supervision", + caution: "Source review required — open the record.", + }, + { + slug: "solution-focused-brief-therapy", + name: "Solution-focused brief therapy", + short: "SFBT", + category: "Community & Casework Support", + fit: "Goal-directed work where the presenting problem is circumscribed.", + time: "3–6 sessions", + complexity: "Can be delivered by trained non-specialists", + caution: "Source review required — open the record.", + }, + { + slug: "family-intervention-for-psychosis", + name: "Family Intervention for Psychosis", + short: "FIp", + category: "Psychosis & Rehabilitation Therapies", + fit: "Relapse prevention where family contact is high.", + time: "10+ sessions over 3 months", + complexity: "Full team and supervision", + caution: "Source review required — open the record.", + }, + { + slug: "social-skills-training-sst", + name: "Social Skills Training (SST)", + short: "SST", + category: "Psychosis & Rehabilitation Therapies", + fit: "Functional deficits in role and social performance.", + time: "Programme · weekly group", + complexity: "Trained facilitator", + caution: "Source review required — open the record.", + }, + { + slug: "cognitive-remediation-therapy-crt", + name: "Cognitive Remediation Therapy (CRT)", + short: "CRT", + category: "Psychosis & Rehabilitation Therapies", + fit: "Cognitive impairment limiting rehabilitation or return to work.", + time: "20–40 sessions", + complexity: "Trained facilitator", + caution: "Source review required — open the record.", + }, + { + slug: "contingency-management-cm", + name: "Contingency Management (CM)", + short: "CM", + category: "Substance Use Therapies", + fit: "Stimulant and opioid use where abstinence can be verified.", + time: "Programme · 12+ weeks", + complexity: "Service-level protocol required", + caution: "Source review required — open the record.", + }, + { + slug: "brief-supportive-psychotherapy", + name: "Brief supportive psychotherapy", + short: "BSP", + category: "Foundational & Engagement Therapies", + fit: "Engagement and stabilisation where structured work is premature.", + time: "Flexible · 20–50 min", + complexity: "Any trained clinician", + caution: "Source review required — open the record.", + }, + { + slug: "problem-management-plus-pm", + name: "Problem Management Plus / PM+", + short: "PM+", + category: "Self-Help & Digital Therapies", + fit: "Common mental disorders in low-resource or stepped-care settings.", + time: "5 sessions · 90 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.", + }, + { + slug: "therapy-compare-perfected", + letter: "C+", + name: "The tray, perfected", + tagline: "C with its three real costs removed. The one to judge.", + }, +]; + +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..b6b68414d1 --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/sheet.tsx @@ -0,0 +1,473 @@ +"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-perfected.tsx b/src/components/therapy-compare-picker-mockups/tray-perfected.tsx new file mode 100644 index 0000000000..aa5a358b2f --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/tray-perfected.tsx @@ -0,0 +1,643 @@ +"use client"; + +import { ArrowLeft, Check, ChevronUp, Copy, FileText, Plus, Scale, Search, Trash2, X } from "lucide-react"; +import { useEffect, useRef, useState, type UIEvent } 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, perfected. + * + * Same model as the first tray study — you fill the set where the therapies + * are, and the compare screen only ever holds a comparison — with the four + * things that study got wrong fixed: + * + * 1. The tray does not exist until something is in it. An empty Therapy + * screen is exactly as tall as it is today. + * 2. The tray and the composer are one bottom stack that hides together on + * scroll and takes a zero content reserve while hidden, which is what + * `docs/search-chrome-behaviour.md` requires of phone chrome. + * 3. You can add from a therapy record, not only from a list — that is the + * whole point of carrying a set, and the first study never showed it. + * 4. Arrival is legible: the tray rises the first time, and the slot it + * filled marks itself, so a tap that changes state off-screen still + * reads as having done something. + * ------------------------------------------------------------------ */ + +/** + * The real list is 205 records. The prototype shows every fixture rather than a + * handful, because a short list cannot demonstrate scroll-hide honestly — it + * un-scrolls the moment the reserve is released. + */ + +/** Heights the reserve maths depends on, kept in one place and to scale. */ +const COMPOSER_H = 62; +const TRAY_H = 64; + +function useReducedMotion() { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReduced(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + return reduced; +} + +type Screen = { kind: "list" } | { kind: "record"; slug: string } | { kind: "compare" }; + +function PerfectedPhone() { + const set = useCompareSet(); + const reduced = useReducedMotion(); + const [screen, setScreen] = useState({ kind: "list" }); + const [expanded, setExpanded] = useState(false); + const [query, setQuery] = useState(""); + const [hidden, setHidden] = useState(false); + const [landed, setLanded] = useState(null); + const lastScroll = useRef(0); + + const count = set.selected.length; + const hasTray = count > 0; + // Derived, never latched: emptying the tray cannot strand you on a blank + // comparison, whichever control did the emptying. + const showCompare = screen.kind === "compare" && count >= 2; + const record = screen.kind === "record" ? bySlug(screen.slug) : undefined; + + // Hidden chrome takes a zero reserve — not a residual pad, not the safe-area + // inset. Visible chrome reserves exactly its own height. + const chromeVisible = !hidden || expanded; + const reserve = chromeVisible ? COMPOSER_H + (hasTray ? TRAY_H : 0) : 0; + + function onScroll(event: UIEvent) { + const top = event.currentTarget.scrollTop; + const delta = top - lastScroll.current; + if (Math.abs(delta) < 6) return; + lastScroll.current = top; + if (expanded) return; + setHidden(delta > 0 && top > 40); + } + + function add(slug: string) { + if (set.full || set.selected.includes(slug)) return; + const index = count; + set.add(slug); + setHidden(false); + setLanded(index); + window.setTimeout(() => setLanded(null), 900); + } + + const transition = reduced ? undefined : "transform 220ms cubic-bezier(0.22, 1, 0.36, 1)"; + + return ( + + + + {/* ---------------- Compare ---------------- */} + {showCompare ? ( +
+ +

+ {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 ( + + +
+ + +
+
+ + +
+ + + + + + +
+
+ + +
+ + + + + + +
+
+ + +
+ ); +} 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..f9959255f5 --- /dev/null +++ b/src/components/therapy-compare-picker-mockups/tray.tsx @@ -0,0 +1,463 @@ +"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, + PhoneComposer, + 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), + ); + // The claim this direction makes is that a compare screen is unreachable + // without a set, so emptying the tray while reading one has to fall back to + // browse rather than leave a blank comparison mounted. + const showCompare = view === "compare" && count >= 2; + + return ( + + + + {!showCompare ? ( + <> +
+
+
+
+
    + {results.map((therapy) => { + const held = set.selected.includes(therapy.slug); + const blocked = !held && set.full; + return ( +
  • +
    +

    {therapy.name}

    +

    + {therapy.category} +

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

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

+ +
+ +
+
+ )} + + {/* The tray. Present on every Therapy screen, and drawn in flow directly + above the real phone composer so the bottom-chrome stack this + direction has to negotiate is visible rather than assumed away. */} + {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 ( + + + + +
+ + +
+
+ + +
+ + + + + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+ ); +}