Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 27 additions & 24 deletions src/components/therapy-compass/therapy-card.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@ import { ignoreUnavailableActivation } from "@/components/ui-primitives";
import { useTcBindings } from "./bindings";
import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select";
import type { Therapy } from "./data/types";
import { accentControl, outlineControl, therapyBtn } from "./controls";
import { accentControl, iconControl, outlineControl, therapyBtn } from "./controls";
import {
AlertIcon,
ChevronRightIcon,
Expand DownExpand Up@@ -47,45 +47,48 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
const sheetShort = therapy.patientSheetAvailable ? "Sheet" : "No sheet";

return (
<article className="relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-soft)]">
<article
data-therapy-result-card
className="relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-soft)]"
>
<button
type="button"
className={`${therapyBtn} absolute top-3 right-3 z-[10] inline-flex h-tap w-tap cursor-not-allowed items-center justify-center rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--disabled)] opacity-65 sm:top-3.5 sm:right-4`}
className={`${iconControl} absolute top-3 right-3 z-[10] sm:top-3.5 sm:right-4`}
aria-disabled="true"
onClick={ignoreUnavailableActivation}
title="Favourite saving is not available yet"
aria-label="Favourite saving is not available yet"
>
<HeartIcon size={17} />
</button>
<div className="grid grid-cols-1 items-start gap-3 px-4 py-3.5 pr-[calc(0.75rem+var(--spacing-tap)+0.5rem)] md:grid-cols-[minmax(240px,1fr)_minmax(320px,1.35fr)] md:gap-4 md:px-5 md:py-4 md:pr-[calc(1rem+var(--spacing-tap)+0.75rem)]">
<div className="flex min-w-0 gap-3">
<IconTile icon={ScaleIcon} size={38} />
<div className="min-w-0 flex-1">
<h3 className="m-0 text-[color:var(--text-heading)] tracking-display text-base font-semibold leading-snug">
{therapy.name}
</h3>
{subtitle ? (
<p className="m-0 mt-1 mb-2 line-clamp-2 text-sm-minus leading-snug text-[color:var(--text-muted)]">
{subtitle}
</p>
) : (
<div className="mb-2" />
)}
<TagRow tags={tags} max={3} wrap={false} />
</div>
<div className="grid grid-cols-1 items-start gap-3 px-4 pt-3.5 md:grid-cols-[minmax(240px,1fr)_minmax(320px,1.35fr)] md:gap-4 md:px-5 md:py-4 md:pr-[calc(1rem+var(--spacing-tap)+0.75rem)]">
<div data-therapy-result-copy className="min-w-0 pr-[calc(var(--spacing-tap)+0.5rem)] md:pr-0">
<h3 className="m-0 text-base font-semibold leading-snug tracking-display text-[color:var(--text-heading)]">
{therapy.name}
</h3>
{subtitle ? (
<p className="m-0 mt-1 mb-2 line-clamp-2 text-sm-minus leading-snug text-[color:var(--text-muted)]">
{subtitle}
</p>
) : (
<div className="mb-2" />
)}
<TagRow tags={tags} max={3} wrap={false} />
</div>

<div className="grid grid-cols-1 gap-px overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--border)] sm:grid-cols-3">
<div
data-therapy-result-evidence
className="-mx-4 grid grid-cols-1 gap-px overflow-hidden border-y border-[color:var(--border)] bg-[color:var(--border)] sm:mx-0 sm:grid-cols-3 sm:rounded-lg sm:border"
>
<CardCell icon={CrosshairIcon} eyebrow="WHY MATCHED" tone="accent" text={whyMatched} />
<CardCell icon={AlertIcon} eyebrow="AVOID / MODIFY" tone="warning" text={avoidModify} />
<CardCell icon={ClockIcon} eyebrow="BEST FIT" tone="muted" text={bestFit} />
</div>
</div>
<div className="grid grid-cols-3 gap-2 px-4 pb-3.5 sm:px-5 sm:pb-4">
<div data-therapy-result-actions className="grid grid-cols-3 gap-2 px-4 py-3.5 sm:px-5 sm:pb-4 md:pt-0">
<button
type="button"
className={`${therapyBtn} ${accentControl} w-full px-2 text-xs sm:px-[18px] sm:text-sm-minus`}
className={`${therapyBtn} ${accentControl} w-full min-w-0 px-2 text-xs sm:px-[18px] sm:text-sm-minus`}
onClick={() => b.open(therapy.slug)}
aria-label="Open record"
>
Expand All@@ -95,7 +98,7 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
</button>
<button
type="button"
className={`${therapyBtn} ${outlineControl} w-full px-2 text-xs sm:px-4 sm:text-sm-minus`}
className={`${therapyBtn} ${outlineControl} w-full min-w-0 px-2 text-xs sm:px-4 sm:text-sm-minus`}
onClick={() => b.toggleCompare(therapy.slug)}
aria-pressed={inCompare}
aria-label={inCompare ? "In compare" : "Compare"}
Expand All@@ -106,7 +109,7 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
</button>
<button
type="button"
className={`${therapyBtn} ${outlineControl} w-full px-2 text-xs sm:px-4 sm:text-sm-minus`}
className={`${therapyBtn} ${outlineControl} w-full min-w-0 px-2 text-xs sm:px-4 sm:text-sm-minus`}
onClick={() => {
if (!therapy.patientSheetAvailable) return;
b.openSheet(therapy.slug);
Expand Down
19 changes: 17 additions & 2 deletions tests/therapy-compass-responsive-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,7 +103,6 @@ describe("Therapy Compass responsive contract", () => {
});

it("marks every fixed screen/card grid for phone reflow without changing its desktop template", () => {
expect(responsiveStackCount(therapyCardSource)).toBeGreaterThanOrEqual(1);
expect(homeSource).toContain("ModeHomeMain");
expect(homeSource).toContain("ModeHomeTemplate");
expect(modeHomeTemplateSource).toContain("sm:grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]");
Expand DownExpand Up@@ -146,6 +145,21 @@ describe("Therapy Compass responsive contract", () => {
expect(allScreens).toMatch(/sm:grid-cols-\[/);
});

it("lets result-card evidence and actions use the phone width without forcing the desktop grid", () => {
const resultCardSource = therapyCardSource.slice(
therapyCardSource.indexOf("export function ResultCard"),
therapyCardSource.indexOf("function CardCell"),
);

expect(resultCardSource).toContain("data-therapy-result-card");
expect(resultCardSource).toContain("md:grid-cols-[minmax(240px,1fr)_minmax(320px,1.35fr)]");
expect(resultCardSource).toContain("-mx-4 grid grid-cols-1");
expect(resultCardSource).toContain("sm:grid-cols-3");
expect(resultCardSource).toMatch(/data-therapy-result-actions\s+className="grid grid-cols-3/);
expect(resultCardSource).toContain('<span className="sm:hidden">Open</span>');
expect(resultCardSource).not.toContain("<IconTile");
});

it("renders the unavailable Favourite action honestly disabled", () => {
const favouriteButton = therapyCardSource.match(
/<button[\s\S]*?title="Favourite saving is not available yet"[\s\S]*?<\/button>/,
Expand All@@ -160,7 +174,8 @@ describe("Therapy Compass responsive contract", () => {
expect(favouriteButton).not.toMatch(/(^|\s)disabled(\s|=|$)/);
expect(favouriteButton).toContain("onClick={ignoreUnavailableActivation}");
expect(favouriteButton).toContain('aria-label="Favourite saving is not available yet"');
expect(favouriteButton).toContain("cursor-not-allowed");
expect(favouriteButton).toContain("iconControl");
expect(controlsSource).toContain("aria-disabled:cursor-not-allowed");
});

it("keeps search result cards dense: single-row tags, top favourite, clamped match cells", () => {
Expand Down
81 changes: 81 additions & 0 deletions tests/ui-route-coverage.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,87 @@ test.describe("previously uncovered production routes", () => {
);
});

test("Therapy result cards use the full phone width with a symmetric action row", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await gotoApp(page, "/therapy-compass/search?q=CBT&run=1");

const card = page.locator("[data-therapy-result-card]").first();
await expect(card).toBeVisible({ timeout: 30_000 });

for (const width of [320, 390, 639, 768, 1440, 1920]) {
await page.setViewportSize({ width, height: width < 768 ? 844 : 900 });
await expectNoHorizontalOverflow(page);

const layout = await card.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const copy = element.querySelector<HTMLElement>("[data-therapy-result-copy]")!.getBoundingClientRect();
const evidence = element.querySelector<HTMLElement>("[data-therapy-result-evidence]")!.getBoundingClientRect();
const actions = element.querySelector<HTMLElement>("[data-therapy-result-actions]")!;
const buttons = [...actions.querySelectorAll<HTMLButtonElement>("button")].map((button) => {
const buttonBounds = button.getBoundingClientRect();
return {
left: buttonBounds.left,
right: buttonBounds.right,
top: buttonBounds.top,
width: buttonBounds.width,
height: buttonBounds.height,
};
});
return {
card: { left: bounds.left, right: bounds.right },
copyLeft: copy.left,
evidence: { left: evidence.left, right: evidence.right },
buttons,
};
});

if (width < 640) {
expect(Math.abs(layout.evidence.left - layout.card.left), `${width}px evidence left edge`).toBeLessThanOrEqual(
1,
);
expect(
Math.abs(layout.card.right - layout.evidence.right),
`${width}px evidence right edge`,
).toBeLessThanOrEqual(1);
expect(layout.copyLeft - layout.card.left, `${width}px copy inset`).toBeGreaterThanOrEqual(15);
expect(new Set(layout.buttons.map((button) => Math.round(button.top))).size, `${width}px button row`).toBe(1);
expect(
Math.max(...layout.buttons.map((button) => button.width)) -
Math.min(...layout.buttons.map((button) => button.width)),
`${width}px equal action widths`,
).toBeLessThanOrEqual(1);
for (const button of layout.buttons) {
expect(button.height, `${width}px action target`).toBeGreaterThanOrEqual(48);
}
}
}

await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce" });
const compare = card.locator("[data-therapy-result-actions] button").nth(1);
await expect(compare).toHaveAccessibleName("Compare");
await compare.focus();
const focusStyle = await compare.evaluate((element) => {
const style = getComputedStyle(element);
return { outlineStyle: style.outlineStyle, outlineWidth: Number.parseFloat(style.outlineWidth) };
});
expect(focusStyle.outlineStyle).not.toBe("none");
expect(focusStyle.outlineWidth).toBeGreaterThanOrEqual(2);
await testInfo.attach("therapy-result-card-phone", {
body: await card.screenshot(),
contentType: "image/png",
});

await page.emulateMedia({ forcedColors: "active", reducedMotion: "reduce" });
await expectNoHorizontalOverflow(page);
await expect(card.locator("[data-therapy-result-actions] button")).toHaveCount(3);

await compare.focus();
await page.keyboard.press("Space");
await expect(page).toHaveURL(/\/therapy-compass\/compare$/);
await expect(page.getByRole("heading", { name: "Therapy Comparison", level: 1 })).toBeVisible();
});

test("DSM home renders responsively and opens comparison", async ({ page }) => {
await proveRenderedRoute(
page,
Expand Down
Loading