diff --git a/src/app/globals.css b/src/app/globals.css index 4a70b3ef9..b10398895 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1221,6 +1221,96 @@ summary::-webkit-details-marker { display: none; } +/* Medication result views use full-bleed structure on phones while keeping + readable content inside the device safe area. The dashboard content shell + contributes 0.75rem phone gutters, so the workspace cancels only that known + inset rather than using 100vw (which can create page-level overflow). */ +@media (max-width: 639px) { + .medication-results-workspace { + width: calc(100% + 1.5rem); + max-width: none; + margin-inline: -0.75rem; + } + + .medication-results-inset { + padding-left: max(1rem, var(--safe-area-left)); + padding-right: max(1rem, var(--safe-area-right)); + } + + .medication-also-matches, + .medication-patient-strip { + border-right: 0; + border-left: 0; + border-radius: 0; + box-shadow: none; + } + + .medication-also-matches, + .medication-patient-strip > summary, + .medication-patient-strip > div { + padding-left: max(1rem, var(--safe-area-left)); + padding-right: max(1rem, var(--safe-area-right)); + } + + .medication-filter-strip { + overscroll-behavior-inline: contain; + scroll-padding-left: max(1rem, var(--safe-area-left)); + scroll-padding-right: max(1rem, var(--safe-area-right)); + padding-left: max(1rem, var(--safe-area-left)); + padding-right: max(1rem, var(--safe-area-right)); + } + + .medication-mobile-results { + gap: 0; + border-block: 1px solid var(--border); + background: var(--surface-raised); + } + + .medication-mobile-result { + border-top: 0; + border-right: 0; + border-left: 0; + border-radius: 0; + padding-block: 0.75rem; + padding-left: max(1rem, var(--safe-area-left)); + padding-right: max(1rem, var(--safe-area-right)); + box-shadow: none; + } + + .medication-mobile-result:last-child { + border-bottom: 0; + } + + .medication-mobile-result[data-selected="true"] { + border-color: var(--border); + background: color-mix(in srgb, var(--clinical-accent-soft) 42%, var(--surface-raised)); + box-shadow: inset 3px 0 0 var(--clinical-accent); + } + + .medication-mobile-result:focus-visible { + outline-offset: -2px; + } +} + +/* Wide phones in landscape can cross the sm breakpoint while still exposing + substantial left/right cutout insets. Preserve the normal tablet card gutter, + then add only the safe-area remainder so text never sits beneath a notch. */ +@media (min-width: 640px) and (max-width: 1023px) { + .medication-results-inset, + .medication-also-matches, + .medication-filter-strip { + padding-left: max(0px, calc(var(--safe-area-left) - 1rem)); + padding-right: max(0px, calc(var(--safe-area-right) - 1rem)); + } + + .medication-patient-strip > summary, + .medication-patient-strip > div, + .medication-mobile-result { + padding-left: max(0.75rem, calc(var(--safe-area-left) - 0.25rem)); + padding-right: max(0.75rem, calc(var(--safe-area-right) - 0.25rem)); + } +} + /* Answer-thread follow-ups: the label reads as an eyebrow above the chips so wrapped chip rows stay ragged-right without a floating side label. Children must stretch so the chip row wraps at the column edge instead of overflowing. */ diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 706cae5c8..76b687e4c 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -166,7 +166,13 @@ function StatusNotice({ authUnavailable, apiUnavailable, setupWarning, -}: Pick) { + className, +}: Pick< + MedicationPrescribingWorkspaceProps, + "realDataReady" | "authUnavailable" | "apiUnavailable" | "setupWarning" +> & { + className?: string; +}) { if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null; const message = authUnavailable ? isDeployedClinicalKb() @@ -179,7 +185,12 @@ function StatusNotice({ : setupWarning || "Medication search setup is still warming up."; return ( -
+
{message}
); @@ -268,7 +279,7 @@ function FilterStrip({ }) { return (
{medicationResultFilters.map((filter) => { @@ -454,12 +465,12 @@ function MedicationResults({ const activeFilterLabel = medicationResultFilters.find((filter) => filter.id === activeFilter)?.label ?? "filtered"; return ( -
+
- -
+ +

Medication search

@@ -471,21 +482,25 @@ function MedicationResults({

- + - {catalog.loading ? ( -

Loading medication catalogue…

- ) : catalog.error ? ( -

- {catalog.error} -

+ {catalog.loading || catalog.error ? ( +
+ {catalog.loading ? ( +

Loading medication catalogue…

+ ) : ( +

+ {catalog.error} +

+ )} +
) : null} {!catalog.loading && !catalog.error && resultCount === 0 ? ( totalAvailable > 0 ? ( -
+
) : ( - +
+ +
) ) : null} {!catalog.loading && !catalog.error && resultCount > 0 ? ( -
+
Medication Dose @@ -598,12 +615,12 @@ function MedicationResults({
) : null} -
+
{rows.map((row, index) => { const result = row.result; const selected = index === 0 && Boolean(query.trim()); const cardClassName = cn( - "min-w-0 w-full rounded-lg border bg-[color:var(--surface-raised)] p-2 text-left shadow-[var(--shadow-inset)] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + "medication-mobile-result min-w-0 w-full rounded-lg border bg-[color:var(--surface-raised)] p-2 text-left shadow-[var(--shadow-inset)] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", selected ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)]/35" : result.href @@ -655,6 +672,7 @@ function MedicationResults({ key={result.id} href={result.href} data-testid={`medication-result-${result.id}-phone`} + data-selected={selected ? "true" : "false"} className={cardClassName} > {cardContent} @@ -663,7 +681,12 @@ function MedicationResults({ } return ( -
+
{cardContent}
); @@ -675,6 +698,7 @@ function MedicationResults({ authUnavailable={authUnavailable} apiUnavailable={apiUnavailable} setupWarning={setupWarning} + className="medication-results-inset" />
); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index cab282b55..08f4c8a62 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -1,5 +1,6 @@ import type { Route } from "playwright-core"; import { expect, test, type Page } from "playwright/test"; +import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; const longTitle = "Extremely long synthetic shared-care guideline title covering lithium clozapine perinatal risk ADHD medication review emergency escalation and outpatient monitoring pathways"; @@ -201,6 +202,61 @@ async function mockStressData(page: Page) { }); } +async function mockMedicationStressData(page: Page) { + await mockStressData(page); + + const records = loadMedicationSnapshot(); + const orderedRecords = [ + ...records.filter((record) => record.slug === "acamprosate"), + ...records.filter((record) => record.slug !== "acamprosate"), + ].slice(0, 12); + + await page.route(/\/api\/medications(?:\?.*)?$/, async (route) => { + await route.fulfill({ + json: { + records, + matches: orderedRecords.map((medication, index) => ({ + medication, + result: { + id: medication.slug, + name: medication.name, + indication: `${medication.subclass || medication.category} with deliberately extended indication text for narrow-screen wrapping`, + match: index === 0 ? "Exact clinical fit" : "Related match", + dose: "Initial and maintenance dosing with a deliberately extended regimen that must wrap without widening the viewport", + ceiling: "Maximum recommended dose with renal and hepatic adjustment", + action: + "Review contraindications, renal function, hepatic function, interactions, pregnancy status, monitoring requirements, and follow-up before prescribing.", + actionTone: index % 2 === 0 ? "danger" : "warning", + tone: index === 0 ? "teal" : "slate", + href: `/medications/${medication.slug}`, + }, + score: 100 - index, + reasons: ["Responsive stress fixture"], + })), + total: records.length, + governance: {}, + demoMode: true, + }, + }); + }); + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + const query = new URL(route.request().url()).searchParams.get("q")?.trim() ?? ""; + await route.fulfill({ json: { query, groups: [], tookMs: 0, domainOrder: [], demoMode: true } }); + }); + + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + !["localhost", "127.0.0.1", "::1"].includes(url.hostname) + ) { + await route.abort("blockedbyclient"); + return; + } + await route.fallback(); + }); +} + async function expectNoPageHorizontalOverflow(page: Page) { const overflow = await page.evaluate(() => { const documentWidth = Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0); @@ -368,3 +424,97 @@ test.describe("Clinical KB long-content stress coverage", () => { }); } }); + +test.describe("Medication responsive stress coverage", () => { + test("full-bleed phone rows and tablet cards remain safe across breakpoint boundaries", async ({ page }) => { + test.setTimeout(90_000); + await mockMedicationStressData(page); + await page.setViewportSize({ width: 320, height: 720 }); + await page.goto("/?mode=prescribing&q=acamprosate%20renal%20dose&run=1", { waitUntil: "domcontentloaded" }); + + const phoneResult = page.getByTestId("medication-result-acamprosate-phone"); + const desktopResult = page.getByTestId("medication-result-acamprosate-desktop"); + await expect(phoneResult).toBeVisible({ timeout: 30_000 }); + await expect(phoneResult).toHaveAttribute("data-selected", "true"); + + const viewports = [ + { width: 320, height: 720 }, + { width: 390, height: 844 }, + { width: 639, height: 820 }, + { width: 640, height: 820 }, + { width: 768, height: 1024 }, + { width: 1024, height: 900 }, + { width: 1440, height: 920 }, + { width: 1920, height: 1080 }, + ] as const; + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))), + ); + await expectNoPageHorizontalOverflow(page); + + if (viewport.width < 1024) { + await expect(phoneResult).toBeVisible(); + await expect(desktopResult).toBeHidden(); + + const metrics = await page.evaluate(() => { + const workspace = document.querySelector(".medication-results-workspace"); + const patient = document.querySelector(".medication-patient-strip"); + const filters = document.querySelector(".medication-filter-strip"); + const card = document.querySelector('[data-testid="medication-result-acamprosate-phone"]'); + const firstFilter = filters?.querySelector("button"); + if (!workspace || !patient || !filters || !card || !firstFilter) return null; + const workspaceRect = workspace.getBoundingClientRect(); + const patientRect = patient.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + const filterRect = firstFilter.getBoundingClientRect(); + const cardStyle = getComputedStyle(card); + return { + workspaceLeft: workspaceRect.left, + workspaceRight: workspaceRect.right, + patientLeft: patientRect.left, + patientRight: patientRect.right, + cardLeft: cardRect.left, + cardRight: cardRect.right, + cardPaddingLeft: Number.parseFloat(cardStyle.paddingLeft), + cardPaddingRight: Number.parseFloat(cardStyle.paddingRight), + filterLeft: filterRect.left, + filterHeight: filterRect.height, + }; + }); + expect(metrics).not.toBeNull(); + expect(metrics?.filterHeight ?? 0).toBeGreaterThanOrEqual(42); + + if (viewport.width <= 639) { + expect(Math.abs(metrics?.workspaceLeft ?? Number.POSITIVE_INFINITY)).toBeLessThanOrEqual(1); + expect(Math.abs((metrics?.workspaceRight ?? 0) - viewport.width)).toBeLessThanOrEqual(1); + expect(Math.abs(metrics?.patientLeft ?? Number.POSITIVE_INFINITY)).toBeLessThanOrEqual(1); + expect(Math.abs((metrics?.patientRight ?? 0) - viewport.width)).toBeLessThanOrEqual(1); + expect(Math.abs(metrics?.cardLeft ?? Number.POSITIVE_INFINITY)).toBeLessThanOrEqual(1); + expect(Math.abs((metrics?.cardRight ?? 0) - viewport.width)).toBeLessThanOrEqual(1); + expect(metrics?.cardPaddingLeft ?? 0).toBeGreaterThanOrEqual(15); + expect(metrics?.cardPaddingRight ?? 0).toBeGreaterThanOrEqual(15); + expect(metrics?.filterLeft ?? 0).toBeGreaterThanOrEqual(15); + } else { + expect(metrics?.cardLeft ?? 0).toBeGreaterThanOrEqual(12); + expect((metrics?.cardRight ?? viewport.width) + 12).toBeLessThanOrEqual(viewport.width); + } + } else { + await expect(desktopResult).toBeVisible(); + await expect(phoneResult).toBeHidden(); + } + } + + await page.setViewportSize({ width: 320, height: 720 }); + const scrollGeometry = await page.locator("main#main-content").evaluate((main) => ({ + clientHeight: main.clientHeight, + scrollHeight: main.scrollHeight, + pageHeight: document.documentElement.scrollHeight, + viewportHeight: document.documentElement.clientHeight, + })); + expect(scrollGeometry.scrollHeight).toBeGreaterThan(scrollGeometry.clientHeight); + expect(scrollGeometry.pageHeight - scrollGeometry.viewportHeight).toBeLessThanOrEqual(2); + }); +});