diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts new file mode 100644 index 000000000..5478a0919 --- /dev/null +++ b/e2e/mobile-density.spec.ts @@ -0,0 +1,155 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + LONG_HEADLINE_BRIEFING, + MOCK_SCORE_RINGS, + mockDashboardSnapshot, + POPULATED_SUMMARIES, +} from "./utils/mock-dashboard-snapshot"; + +/** + * Phone-width density guard (390×844 iPhone class, 360×780 small + * Android class). + * + * Two invariants, per the report against the dashboard hero (a long + * briefing-signal headline squeezed into a one-word-per-line column + * beside a wide delta, the delta spilling past the tile edge at 360 px): + * + * 1. No horizontal page overflow — `scrollWidth` never exceeds the + * viewport width (1 px sub-pixel tolerance). + * 2. Nothing escapes its tile — for every card / hero band / list + * row, no descendant's visible box crosses the tile's left or + * right edge (clip-aware: content genuinely hidden by an inner + * `overflow-hidden` wrapper below the tile is fine; content cut + * off at the tile's own edge is the bug). + * + * The snapshot mock pins the worst case: hero band on, three score + * rings, and a fresh briefing whose signal rows pair wrapping German + * headlines with wide delta strings. + * + * Desktop project only — the assertions are viewport-driven via + * `setViewportSize` (the `ipad-viewport.spec.ts` pattern). + */ +const VIEWPORTS = [ + { width: 390, height: 844 }, + { width: 360, height: 780 }, +] as const; + +const ROUTES = ["/", "/insights"] as const; + +interface TileEscape { + tile: string; + child: string; + overRight: number; + overLeft: number; + text: string; +} + +/** Serialised into the page: measure children escaping their tile. */ +function collectTileEscapes(): TileEscape[] { + const escapes: TileEscape[] = []; + const tiles = document.querySelectorAll( + '[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]', + ); + const TOLERANCE = 1; + for (const tile of tiles) { + const tileRect = tile.getBoundingClientRect(); + if (tileRect.width === 0) continue; + for (const child of tile.querySelectorAll("*")) { + const rect = child.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + if (getComputedStyle(child).position === "fixed") continue; + // Clamp by clipping ancestors BELOW the tile only — the tile's + // own overflow-hidden cutting content off is exactly the bug. + let left = rect.left; + let right = rect.right; + let ancestor = child.parentElement; + while (ancestor && ancestor !== tile) { + if (getComputedStyle(ancestor).overflowX !== "visible") { + const ancestorRect = ancestor.getBoundingClientRect(); + left = Math.max(left, ancestorRect.left); + right = Math.min(right, ancestorRect.right); + } + ancestor = ancestor.parentElement; + } + if (right <= left) continue; + const overRight = right - tileRect.right; + const overLeft = tileRect.left - left; + if (overRight > TOLERANCE || overLeft > TOLERANCE) { + escapes.push({ + tile: tile.getAttribute("data-slot") ?? tile.tagName, + child: + child.tagName.toLowerCase() + + (child.getAttribute("data-slot") + ? `[${child.getAttribute("data-slot")}]` + : ""), + overRight: Math.round(overRight * 10) / 10, + overLeft: Math.round(overLeft * 10) / 10, + text: (child.textContent ?? "").trim().slice(0, 60), + }); + } + } + } + return escapes; +} + +test.describe("phone-width density guard", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + }); + + for (const viewport of VIEWPORTS) { + for (const route of ROUTES) { + test(`no overflow and no tile escape on ${route} at ${viewport.width}x${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await mockDashboardSnapshot(page, { + summaries: POPULATED_SUMMARIES, + heroVisible: true, + briefing: LONG_HEADLINE_BRIEFING, + scoreRings: MOCK_SCORE_RINGS, + }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + if (route === "/") { + // The guard must not pass vacuously — the hero band and its + // long-headline spotlight rows have to actually render. + await expect( + page.locator('[data-slot="dashboard-hero"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="dashboard-briefing-spotlight-row"]'), + ).toHaveCount(3); + } + + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + innerWidth: window.innerWidth, + })); + expect( + dims.scrollWidth, + `page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + expect( + dims.bodyScrollWidth, + `body scrollWidth=${dims.bodyScrollWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + + const escapes = await page.evaluate(collectTileEscapes); + expect( + escapes, + `tile escapes: ${JSON.stringify(escapes, null, 2)}`, + ).toEqual([]); + }); + } + } +}); diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index e1f626569..08a58c947 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -4,6 +4,8 @@ import { DASHBOARD_WIDGET_CATALOGUE_IDS, DEFAULT_DASHBOARD_LAYOUT, } from "@/lib/dashboard-layout"; +import type { DailyBriefing } from "@/lib/ai/schema"; +import type { DashboardScoreRing } from "@/lib/dashboard/score-rings"; import type { DataSummary } from "@/lib/analytics/trends"; import type { DashboardSnapshot, @@ -172,6 +174,54 @@ function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] { })); } +/** + * Long-headline briefing fixture for the hero density guard: every + * signal pairs a wrapping German headline with a wide delta string — + * the exact shape that squeezed the spotlight rows on phone widths. + */ +export const LONG_HEADLINE_BRIEFING: DailyBriefing = { + paragraph: + "Dein systolischer Blutdruck liegt heute deutlich unter deinem Monatsmittel, während die Schlafdauer stabil bleibt.", + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "watch", + headline: + "Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen", + nudge: "Miss heute Abend erneut, um den Wert einzuordnen.", + delta: "−12 mmHg vs. 30-Tage-Mittel", + }, + { + sourceMetric: "sleep", + tone: "info", + headline: + "Schlafdauer in den letzten sieben Nächten etwa eine Stunde kürzer als üblich", + nudge: "Plane heute eine frühere Nachtruhe ein.", + delta: "−58 min", + }, + { + sourceMetric: "compliance", + tone: "good", + headline: "Alle geplanten Dosen der Woche bisher vollständig eingenommen", + nudge: "Weiter so — die Abenddosis steht um 20:00 an.", + delta: "7/7 Tage", + }, + ], + keyFindings: [], +}; + +/** Three-ring hero row fixture (max the hero renders beside the score). */ +export const MOCK_SCORE_RINGS: DashboardScoreRing[] = [ + { id: "READINESS", score: 82, band: "green" }, + { id: "RECOVERY_SCORE", score: 74, band: "yellow" }, + { + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }, +]; + export interface MockSnapshotOptions { /** * Tile summaries keyed by `MeasurementType`. Defaults to the @@ -181,6 +231,12 @@ export interface MockSnapshotOptions { summaries?: Record; /** Artificial latency in ms before the route fulfils (race specs). */ delayMs?: number; + /** Render the dashboard hero band (`layout.heroVisible` opt-in). */ + heroVisible?: boolean; + /** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */ + briefing?: DailyBriefing; + /** Selected hero score rings (renders beside the health-score ring). */ + scoreRings?: DashboardScoreRing[]; } /** Assemble a fully-typed snapshot body from the requested tile data. */ @@ -207,7 +263,9 @@ export function buildMockSnapshot( onboardingTourCompleted: true, greetingHour: 9, }, - layout: DEFAULT_DASHBOARD_LAYOUT, + layout: options.heroVisible + ? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true } + : DEFAULT_DASHBOARD_LAYOUT, layoutCatalogue: buildLayoutCatalogue(), metricStates: {}, targetBands: mockTargetBands(), @@ -226,10 +284,13 @@ export function buildMockSnapshot( nextDueOverdue: false, nextDueMedicationName: null, }, - healthScore: null, - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: null, + healthScore: options.briefing + ? { score: 84, band: "green", delta: -2, restMode: null } + : null, + scoreRings: options.scoreRings, + briefing: options.briefing ?? null, + briefingState: options.briefing ? "ready" : "preparing", + briefingUpdatedAt: options.briefing ? now : null, briefingStale: false, generatedAt: now, }; diff --git a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx index 2d6a6eb34..ed04df2dd 100644 --- a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx +++ b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx @@ -180,4 +180,34 @@ describe("", () => { const rows = html.match(/data-slot="dashboard-briefing-spotlight-row"/g); expect(rows).toHaveLength(3); }); + + it("stacks headline over delta below sm and keeps the row at sm+", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "watch", + headline: + "Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen", + nudge: "n", + delta: "−12 mmHg vs. 30-Tage-Mittel", + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + // Phone-first column, row restored at sm+ — a wide delta must never + // squeeze the headline into a narrow multi-line column (or push + // itself past the tile edge). + expect(html).toMatch( + /class="[^"]*flex min-w-0 flex-1 flex-col gap-1 sm:flex-row[^"]*"/, + ); + // The delta is left-aligned content-width in the stacked state and + // only refuses to shrink in the sm+ row. + expect(html).toMatch( + /data-slot="dashboard-briefing-spotlight-delta"[^>]*class="[^"]*self-start[^"]*sm:shrink-0[^"]*"/, + ); + }); }); diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx index 5954030a8..f18dfa928 100644 --- a/src/components/dashboard/hero/briefing-spotlight.tsx +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -196,7 +196,14 @@ export function BriefingSpotlight({ )} aria-hidden="true" /> -
+ {/* Phone widths stack headline over delta — a long + signal headline beside a long delta squeezed the + headline into a one-word-per-line column and pushed + the delta past the tile edge. At `sm+` the + side-by-side row returns. The headline always wraps + (content is never truncated); only the delta is meta + and may stay on one line. */} +

{stripChartTokens(row.headline)}

@@ -204,7 +211,7 @@ export function BriefingSpotlight({ diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index 3bd9bdb85..8db6630e4 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -102,6 +102,20 @@ describe("", () => { expect(html).toContain("+6 mmHg"); }); + it("stacks headline over delta below sm and keeps the row at sm+", () => { + const html = render(); + // Phone-first column (headline above, delta beneath), side-by-side + // restored at sm+ — a wide delta must never squeeze the headline + // into a narrow multi-line column. + expect(html).toMatch( + /class="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3"/, + ); + // Delta badge stays content-width + left-aligned in the stacked state. + expect(html).toMatch( + /data-slot="daily-briefing-delta"[^>]*class="[^"]*self-start[^"]*sm:shrink-0[^"]*"/, + ); + }); + it("does NOT render a delta badge when delta is null", () => { const html = render( ", () => { ); }); + it("stacks the explainer caption BELOW the delta line, not beside it", () => { + // The caption is a multi-sentence read; rendering it inside the + // delta's inline-flex row squeezed the short delta into a narrow + // multi-line column on every viewport. The wrapper stacks instead. + const html = ssr( + , + ); + const wrapper = html.match( + /data-slot="health-score-card-delta"[^>]*class="([^"]*)"/, + ); + expect(wrapper).not.toBeNull(); + expect(wrapper?.[1]).toContain("space-y-1"); + expect(wrapper?.[1]).not.toContain("inline-flex"); + // The delta line's

closes before the explainer body opens — + // the caption is a sibling below, never a flex neighbour. + expect(html).toMatch( + /vs last week[^<]*<\/span><\/p>]*data-slot="health-score-delta-explainer-body"/, + ); + }); + it("renders four component rows with their values", () => { const html = ssr( @@ -243,7 +245,11 @@ function BriefingRow({ aria-hidden="true" />

-
+ {/* Phone widths stack headline over delta (same treatment as the + dashboard spotlight rows): side-by-side squeezed a long + headline into a narrow multi-line column beside a long delta. + At `sm+` the row layout returns. */} +

{stripChartTokens(headline)}

diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 1dc4a637b..1ecff0abd 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -407,41 +407,52 @@ export function HealthScoreCard({ on phone-class viewports. The explainer only mounts when a numeric delta is available; the "no history yet" branch keeps the existing single-line caption. */} -

{delta === null ? ( - {t("insights.healthScore.deltaUnavailable")} +

{t("insights.healthScore.deltaUnavailable")}

) : ( <> - {delta > 0 && ( -
{/* v1.18.6 — Rest Mode legibility. While an illness episode is active the server suppresses (never penalises) the score, so