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, };