From 48be4c291cfc60c83eb9516fadaa64dbcaddd178 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:25:15 +0800 Subject: [PATCH 1/8] fix(therapy-compass): redesign Pathways mobile picker and step stack Fix phone clipping under the search composer, add a filterable pathway sheet for faster switching, and extract step-stack/picker components with responsive contract and Playwright coverage. Co-authored-by: Cursor --- docs/design-system/COMPONENTS.md | 4 +- docs/design-system/adoption-manifest.json | 6 + playwright.config.ts | 4 +- scripts/playwright-pr-shards.mjs | 5 +- .../therapy-compass/pathway-picker-sheet.tsx | 218 ++++++++++++++++++ .../therapy-compass/pathway-review-label.ts | 29 +++ .../therapy-compass/pathway-step-stack.tsx | 196 ++++++++++++++++ .../screens/pathways-screen.tsx | 177 ++++---------- ...herapy-compass-responsive-contract.test.ts | 10 +- tests/therapy-pathways-mobile.dom.test.tsx | 186 +++++++++++++++ tests/ui-therapy-pathways.spec.ts | 107 +++++++++ 11 files changed, 798 insertions(+), 144 deletions(-) create mode 100644 src/components/therapy-compass/pathway-picker-sheet.tsx create mode 100644 src/components/therapy-compass/pathway-review-label.ts create mode 100644 src/components/therapy-compass/pathway-step-stack.tsx create mode 100644 tests/therapy-pathways-mobile.dom.test.tsx create mode 100644 tests/ui-therapy-pathways.spec.ts diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 3c97e63937..87390ef93a 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -976,7 +976,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `AnswerFooter` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `Button` | controls | yes | yes | inherited-global-root | yes | no | 16 | +| `Button` | controls | yes | yes | inherited-global-root | yes | no | 18 | | `Checkbox` | controls | yes | yes | no | yes | no | 0 | | `Chip` | controls | yes | yes | inherited-global-root | yes | no | 5 | | `ChoiceChip` | controls | yes | yes | inherited-global-root | yes | no | 4 | @@ -1012,7 +1012,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 30 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 31 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index efa4e37396..e083452955 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -281,6 +281,8 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/privacy-quiet-signal-page.tsx", + "src/components/therapy-compass/pathway-picker-sheet.tsx", + "src/components/therapy-compass/pathway-step-stack.tsx", "src/components/therapy-compass/record/compare-action.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", @@ -299,6 +301,8 @@ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/privacy-quiet-signal-page.tsx", + "src/components/therapy-compass/pathway-picker-sheet.tsx", + "src/components/therapy-compass/pathway-step-stack.tsx", "src/components/therapy-compass/record/compare-action.tsx", "src/components/therapy-compass/screens/brief-screen.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", @@ -1661,6 +1665,7 @@ "src/components/forms/form-priority-facts-section.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/therapy-compass/pathway-picker-sheet.tsx", "src/components/therapy-compass/record/key-facts.tsx", "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/tools/tools-search-results-page.tsx", @@ -1694,6 +1699,7 @@ "src/components/forms/form-priority-facts-section.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/therapy-compass/pathway-picker-sheet.tsx", "src/components/therapy-compass/record/key-facts.tsx", "src/components/therapy-compass/therapy-compare-tray.tsx", "src/components/tools/tools-search-results-page.tsx", diff --git a/playwright.config.ts b/playwright.config.ts index 8b02d37438..9bd6a26a04 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,7 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // `tests/playwright-project-isolation.test.ts` asserts every such file on disk is // matched here. const productionSpecPattern = - /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/; const mockupSpecPattern = /.*ui-(answer-chat-perfected-mockup|care-plan-mockup|caring-contact-mockup|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-management|ward-coordinator|ward-roles|ward-discharges)\.spec\.ts/; const mockupTag = /@mockup/; @@ -31,7 +31,7 @@ const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|answer-chat-perfected-mockup|care-plan-mockup|caring-contact-mockup|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-(?:management|coordinator|roles|discharges)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|answer-chat-perfected-mockup|care-plan-mockup|caring-contact-mockup|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-(?:management|coordinator|roles|discharges)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index 0784808898..a1305b1ad9 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -19,7 +19,7 @@ import { childProcessExitCode } from "./child-process-result.mjs"; /** Same matcher as playwright.config.ts `productionSpecPattern` (keep in sync). */ export const productionSpecFilePattern = - /^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/; + /^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|tools|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/; /** * One source of truth for shard membership and its latest hosted timing sample. @@ -44,6 +44,9 @@ export const prUiSpecProfiles = Object.freeze([ // with hosted evidence at the next timing refresh. Moved here from shard 2 by // Task 19 to offset the re-measured Caring Contacts workspace spec. { file: "tests/ui-phone-motion.spec.ts", shard: 1, fullSeconds: 5.0, criticalSeconds: 0 }, + // Added with the Therapy Pathways mobile picker redesign; measured locally at + // ~2 tests. Placed on shard 1 to keep post-critical spread within the 10s ceiling. + { file: "tests/ui-therapy-pathways.spec.ts", shard: 1, fullSeconds: 2.0, criticalSeconds: 0 }, { file: "tests/ui-phone-scroll-routes.spec.ts", shard: 2, fullSeconds: 129.6, criticalSeconds: 0 }, { file: "tests/ui-phone-scroll.spec.ts", shard: 2, fullSeconds: 66.3, criticalSeconds: 0 }, diff --git a/src/components/therapy-compass/pathway-picker-sheet.tsx b/src/components/therapy-compass/pathway-picker-sheet.tsx new file mode 100644 index 0000000000..cd9e76ee2f --- /dev/null +++ b/src/components/therapy-compass/pathway-picker-sheet.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useId, useMemo, useRef, useState } from "react"; +import { Search, Waypoints } from "lucide-react"; + +import { InteractiveRow } from "@/components/ui/interactive-row"; +import { Sheet } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/ui-primitives"; + +import type { Pathway } from "./data/types"; +import { + matchesPathwayFilter, + pathwayReviewBadgeClass, + pathwayReviewLabel, + pathwayRowAccessibleName, +} from "./pathway-review-label"; + +type PathwayListRailProps = { + pathways: Pathway[]; + activeSlug: string; + onSelect: (slug: string) => void; +}; + +export function PathwayListRail({ pathways, activeSlug, onSelect }: PathwayListRailProps) { + return ( +
+
Pathways
+
+ {pathways.map((pathway) => { + const active = pathway.slug === activeSlug; + const reviewed = pathway.reviewStatus === "reviewed"; + return ( + onSelect(pathway.slug)} + > + + + + {pathway.name} + + {pathway.clinicalProblem ?? pathway.summary ?? "Therapy workflow"} + + + {pathway.steps.length} linked steps + + {pathwayReviewLabel(pathway)} + + + + + ); + })} +
+

+ Pathways are generated from imported therapy records. +

+
+ ); +} + +type PathwayPickerSheetProps = { + pathways: Pathway[]; + activePathway: Pathway; + onSelect: (slug: string) => void; +}; + +export function PathwayMobileBar({ pathways, activePathway, onSelect }: PathwayPickerSheetProps) { + const [sheetOpen, setSheetOpen] = useState(false); + const [filter, setFilter] = useState(""); + const panelId = useId(); + const filterId = useId(); + const triggerRef = useRef(null); + const reviewed = activePathway.reviewStatus === "reviewed"; + const filteredPathways = useMemo( + () => pathways.filter((pathway) => matchesPathwayFilter(pathway, filter)), + [pathways, filter], + ); + + const closeSheet = () => { + setSheetOpen(false); + setFilter(""); + }; + + return ( +
+
+
+ + +
+
+

{activePathway.name}

+ + {pathwayReviewLabel(activePathway)} + +
+

+ {activePathway.clinicalProblem ?? activePathway.summary ?? "Therapy workflow"} +

+
+
+ +
+ + + Select a clinical problem workflow. {pathways.length} pathways available. +

+ } + portal + placement="responsive-right" + id={panelId} + testId="therapy-pathway-picker-panel" + footer={ + + } + > + +
+ {filteredPathways.length === 0 ? ( +

No pathways match that filter.

+ ) : null} + {filteredPathways.map((pathway) => { + const active = pathway.slug === activePathway.slug; + const pathwayReviewed = pathway.reviewStatus === "reviewed"; + return ( + { + onSelect(pathway.slug); + closeSheet(); + }} + > + + + + {pathway.name} + + {pathway.clinicalProblem ?? pathway.summary ?? "Therapy workflow"} + + + {pathway.steps.length} linked steps + + {pathwayReviewLabel(pathway)} + + + + + ); + })} +
+
+
+ ); +} diff --git a/src/components/therapy-compass/pathway-review-label.ts b/src/components/therapy-compass/pathway-review-label.ts new file mode 100644 index 0000000000..5a86536ed7 --- /dev/null +++ b/src/components/therapy-compass/pathway-review-label.ts @@ -0,0 +1,29 @@ +import type { Pathway } from "./data/types"; + +export function pathwayRowAccessibleName(pathway: Pathway, active: boolean): string { + const parts = [pathway.name, `${pathway.steps.length} linked steps`, pathwayReviewLabel(pathway)]; + if (active) parts.push("currently selected"); + return parts.join(", "); +} + +export function matchesPathwayFilter(pathway: Pathway, query: string): boolean { + const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return true; + const haystack = [pathway.name, pathway.clinicalProblem, pathway.summary, pathwayReviewLabel(pathway)] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return tokens.every((token) => haystack.includes(token)); +} + +export function pathwayReviewLabel(pathway: Pick): string { + if (pathway.reviewStatus === "reviewed") return "Reviewed"; + if (pathway.incomplete) return "Incomplete"; + return "Needs review"; +} + +export function pathwayReviewBadgeClass(reviewed: boolean): string { + return reviewed + ? "border-[color:var(--success-border)] bg-[color:var(--success-bg)] text-[color:var(--success-text)]" + : "border-[color:var(--warning-border)] bg-[color:var(--warning-bg)] text-[color:var(--warning-text)]"; +} diff --git a/src/components/therapy-compass/pathway-step-stack.tsx b/src/components/therapy-compass/pathway-step-stack.tsx new file mode 100644 index 0000000000..c038c64b90 --- /dev/null +++ b/src/components/therapy-compass/pathway-step-stack.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ChevronRight, Scale, Waypoints } from "lucide-react"; + +import { cardSurface } from "@/components/card-recipes"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/ui-primitives"; + +import type { Pathway, PathwayStep, Therapy } from "./data/types"; + +type PathwayStepStackProps = { + steps: PathwayStep[]; + bySlug: Map; + onOpenRecord: (slug: string) => void; +}; + +function StepCard({ + step, + index, + last, + therapy, + onOpenRecord, + layout, +}: { + step: PathwayStep; + index: number; + last: boolean; + therapy: Therapy | undefined; + onOpenRecord: (slug: string) => void; + layout: "mobile" | "desktop"; +}) { + const title = therapy?.name ?? step.label ?? "Therapy step"; + const description = step.description ?? therapy?.bestUsedFor ?? "Review fit, contraindications and source status."; + const roleLabel = step.label ?? "STEP"; + + if (layout === "mobile") { + return ( +
+ {!last ? ( +
+ ); + } + + return ( +
+
+ + {index + 1} + + {!last ?
+
+ + +
+
{title}
+
{description}
+
+ + {roleLabel} + + {therapy ? ( + + ) : ( +
+
+ ); +} + +export function PathwayStepStack({ steps, bySlug, onOpenRecord }: PathwayStepStackProps) { + return ( + <> +
+ {steps.map((step, index) => { + const therapy = step.therapySlug ? bySlug.get(step.therapySlug) : undefined; + return ( + + ); + })} +
+
+ {steps.map((step, index) => { + const therapy = step.therapySlug ? bySlug.get(step.therapySlug) : undefined; + return ( + + ); + })} +
+ + ); +} + +export function PathwayDetailHeader({ pathway, reviewBadge }: { pathway: Pathway; reviewBadge?: ReactNode }) { + return ( +
+ + +
+
+

{pathway.name}

+ {reviewBadge} +
+

+ {pathway.summary ?? + "A source-linked workflow for reviewing therapy options, delivery constraints and cautions before choosing a next step."} +

+
+
+
+
+ ); +} diff --git a/src/components/therapy-compass/screens/pathways-screen.tsx b/src/components/therapy-compass/screens/pathways-screen.tsx index 65dae23192..64534c80c3 100644 --- a/src/components/therapy-compass/screens/pathways-screen.tsx +++ b/src/components/therapy-compass/screens/pathways-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { Check, ChevronRight, Copy, FileText, ListChecks, Scale, TriangleAlert, Waypoints } from "lucide-react"; +import { Check, Copy, FileText, ListChecks, TriangleAlert, Waypoints } from "lucide-react"; import { cardSurface } from "@/components/card-recipes"; import { Chip } from "@/components/ui/chip"; @@ -10,19 +10,17 @@ import { cn, pageContainer } from "@/components/ui-primitives"; import { Button } from "@/components/ui/button"; import { useTcBindings } from "../bindings"; -import { InteractiveRow } from "@/components/ui/interactive-row"; -import type { Therapy } from "../data/types"; import { LoadingState } from "../ui"; import { useClipboard } from "../use-clipboard"; +import { PathwayMobileBar, PathwayListRail } from "../pathway-picker-sheet"; +import { PathwayDetailHeader, PathwayStepStack } from "../pathway-step-stack"; +import { pathwayReviewBadgeClass, pathwayReviewLabel } from "../pathway-review-label"; export function PathwaysScreen() { const b = useTcBindings(); const bySlug = useMemo(() => new Map(b.therapies.map((t) => [t.slug, t])), [b.therapies]); const pathway = b.selectedPathway; - // Header counts. Summed here rather than in the header markup so the chips - // stay declarative and the totals survive a data shape that grows a step. const linkedStepCount = useMemo(() => b.pathways.reduce((total, p) => total + p.steps.length, 0), [b.pathways]); - // Called before the early return below, because it is a hook. const { copied, copy } = useClipboard(); if (b.loading || !pathway) return ; @@ -31,10 +29,8 @@ export function PathwaysScreen() { pathway.steps .map((step) => (step.therapySlug ? bySlug.get(step.therapySlug) : undefined)) .find((therapy) => therapy?.patientSheetAvailable)?.slug ?? null; - // Every other copy affordance in this mode goes through `useClipboard` -> - // `@/lib/copy-to-clipboard`. This one reached for `navigator.clipboard` - // directly, so it had no fallback path, treated a rejected write as success, - // and gave the reader no confirmation that anything had been copied. + const reviewed = pathway.reviewStatus === "reviewed"; + const copyPathway = () => { const lines = pathway.steps.map((st, i) => { const name = (st.therapySlug ? bySlug.get(st.therapySlug)?.name : null) ?? st.label ?? "Step"; @@ -45,21 +41,8 @@ export function PathwaysScreen() { return (
- {/* The Review queue lived here as the page's one action. It is a curation - surface — which records still need source review — not something a - clinician reads a pathway to reach, so the header no longer carries it; - `/therapy-compass/review` still serves it directly. What replaces it is - scale, not another control: the two counts say how much of the - catalogue this page covers, and the accent chip is the only colour a - phone gets here, since `PageHeader` hides its icon tile below `sm`. - - The description loses "generated from imported therapy records" - because that provenance is already stated twice further down the same - screen — the note under the pathway list and the caution banner — and - on a 390px viewport it was the line that wrapped into the drawer - handle. */} -
- {/* pathway list */} -
-
Pathways
-
- {b.pathways.map((p) => { - const active = p.slug === pathway.slug; - return ( - b.selectPathway(p.slug)}> - - - - {p.name} - - {p.clinicalProblem ?? p.summary ?? "Therapy workflow"} - - - {p.steps.length} linked steps - - {p.reviewStatus === "reviewed" ? "Reviewed" : p.incomplete ? "Incomplete" : "Needs review"} - - - - - ); - })} -
-

- Pathways are generated from imported therapy records. -

-
+ - {/* pathway detail */} -
-
- - -
-
-

{pathway.name}

+
+ + +
+
+
-

- {pathway.summary ?? - "A source-linked workflow for reviewing therapy options, delivery constraints and cautions before choosing a next step."} -

-
-
-
+ } + />
-
- {pathway.steps.map((step, i) => { - const therapy: Therapy | undefined = step.therapySlug ? bySlug.get(step.therapySlug) : undefined; - const last = i === pathway.steps.length - 1; - return ( -
- - {i + 1} - -
- - -
-
- {therapy?.name ?? step.label ?? "Therapy step"} -
-
- {step.description ?? therapy?.bestUsedFor ?? "Review fit, contraindications and source status."} -
-
- - {step.label ?? "STEP"} - - {therapy ? ( - - ) : ( -
-
- ); - })} -
+
-
+