diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 4216f5b82..1b7782963 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -435,3 +435,157 @@ test.describe("Today overview geometry", () => { expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1); }); }); + +/** + * The phone in German, where the score's meta lines are longest. In the + * dial's 80 px column "−6 vs. dein Mittel" and the basis sentence broke into + * three to five rows each; a short lead then left a gap above the facts as + * tall as that column. The lines now run on their own row under the dial. + */ +const GERMAN_SCORE: DailyDigest["score"] = { + value: 63, + band: "yellow", + delta: -6, + deltaReason: null, + steadyWeeks: null, + scoreBasis: { + domains: 2, + recommended: 3, + tier: "partial", + physiological: true, + }, +}; + +const GERMAN_FACTS: DailyDigest["today"] = [ + { + kind: "medications", + label: "Medikamente", + value: "1 von 2 genommen", + href: "/medications", + moduleKey: "medications", + }, + { + kind: "sleep", + label: "Letzte Nacht", + value: "5 Std. 45 Min., 42 Min. weniger als sonst", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitalwerte", + value: "Puls über deinem Bereich", + href: "/insights", + }, +]; + +const GERMAN_STATES: Record = { + "de-short": { + ...BASE, + score: GERMAN_SCORE, + lead: { text: "Dein Puls liegt heute erhöht.", source: "signal" }, + today: GERMAN_FACTS, + }, + "de-long": { + ...BASE, + score: GERMAN_SCORE, + lead: { + text: "Dein Puls liegt heute Abend deutlich über seinem üblichen Niveau, nach einer kürzeren Nacht als sonst; ein ruhiger Abend täte ihm gut.", + source: "briefing", + }, + signalLine: { headline: null, delta: "+34 bpm vs. dein 30-Tage-Mittel" }, + today: GERMAN_FACTS, + }, +}; + +test.describe("Today overview on a German phone", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + for (const viewport of VIEWPORTS.filter((v) => v.width < 768)) { + for (const [state, digest] of Object.entries(GERMAN_STATES)) { + test(`${state} at ${viewport.width}px`, async ({ page }) => { + await page.emulateMedia({ + colorScheme: "dark", + reducedMotion: "reduce", + }); + await page.addInitScript(() => { + window.localStorage.setItem("healthlog-theme", "dark"); + }); + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + await page.waitForLoadState("networkidle"); + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-dark-${state}.png`), + }); + + const box = async (slot: string) => + (await hero.locator(`[data-slot="${slot}"]`).first().boundingBox())!; + const ring = await box("score-ring"); + const lead = await box("today-hero-lead"); + const meta = await box("today-hero-score-meta"); + const block = await box("today-hero-today"); + + // The dial keeps its size and its place beside the lead. + expect(Math.round(ring.width)).toBe(RING_PX.phone); + expect(lead.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH); + + // Each meta line reads on one line, flush with the dial's edge. + const lines = await hero + .locator('[data-slot="today-hero-score-meta"] > span') + .evaluateAll((spans) => + spans.map((span) => { + const rect = span.getBoundingClientRect(); + const lineHeight = parseFloat(getComputedStyle(span).lineHeight); + return { + rows: Math.round(rect.height / lineHeight), + right: rect.right, + }; + }), + ); + expect(lines.length).toBe(2); + for (const line of lines) { + expect(line.rows).toBe(1); + expect( + Math.abs(line.right - (ring.x + ring.width)), + ).toBeLessThanOrEqual(1); + } + + // The meta row follows the read row at once: right under the dial + // beside a short lead, right under a long lead's last line. + // The lead's cell ends with its signal line when there is one. + const signalLine = hero.locator('[data-slot="today-hero-signal"]'); + const signal = + (await signalLine.count()) > 0 + ? await signalLine.boundingBox() + : null; + const leadBottom = signal + ? signal.y + signal.height + : lead.y + lead.height; + const readBottom = Math.max(ring.y + ring.height, leadBottom); + expect(meta.y - readBottom).toBeGreaterThanOrEqual(0); + expect(meta.y - readBottom).toBeLessThanOrEqual(6); + + // No gap above the facts beyond one section step: they start one + // row gap (16 px) under whatever ends lowest, the lead or the meta. + const above = Math.max(leadBottom, meta.y + meta.height); + expect(block.y - above).toBeGreaterThanOrEqual(15); + expect(block.y - above).toBeLessThanOrEqual(17); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } + } +}); diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index d8d8640cf..a334795ed 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -607,8 +607,45 @@ describe(" Today overview", () => { // Full width under lead and ring below md, the leading column from md. expect(cellClass("today-hero-today")).toContain("col-span-2"); expect(cellClass("today-hero-today")).toContain("md:col-span-1"); - expect(cellClass("today-hero-score")).toContain("col-start-2"); - expect(cellClass("today-hero-score")).toContain("md:row-span-2"); + // The slot's own classes, for elements that are grid items themselves. + const slotClass = (slot: string) => + new RegExp(`data-slot="${slot}" class="([^"]*)"`).exec(read)?.[1] ?? ""; + // Below md the dial is the top-right cell and its meta lines a row of + // their own under it; from md up the column spans both rows as before. + expect(slotClass("today-hero-score")).toContain("col-start-2"); + expect(slotClass("today-hero-score")).toContain("row-start-1"); + expect(slotClass("today-hero-score-column")).toContain("contents"); + expect(slotClass("today-hero-score-column")).toContain("md:row-span-2"); + }); + + it("gives the score's meta lines a full-width row on a phone, not the dial's width", () => { + const html = render( + , + "de", + ); + const meta = /data-slot="today-hero-score-meta" class="([^"]*)"/.exec( + html, + )?.[1]; + expect(meta).toContain("col-span-2"); + expect(meta).toContain("md:contents"); + // No line is capped to the 80 px dial any more. + expect(html).not.toContain("max-w-20"); + // The facts move one row down to make room for it. + expect(html).toMatch( + /class="[^"]*row-start-3[^"]*">]*data-slot="today-hero-today"/, + ); }); it("stacks each fact's label above its value, both on the reading edge", () => { diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 65bbd4f4e..d2325fd0c 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -322,76 +322,77 @@ export function TodayHero({ null score paints the ring's honest provisional face at the same footprint, so the column never collapses. The meta lines under it are capped to the dial's width on a phone so the column stays compact. */ - const scoreColumn = (size: "sm" | "responsive") => { - const phoneMeta = size === "responsive" && "max-w-20 md:max-w-none"; - return ( -
( +
+ {/* The ring opens the Insights overview — the destination the + health-score card owns — so every hero ring is a door, not a + poster. */} + -
- {/* The ring opens the Insights overview — the destination the - health-score card owns — so every hero ring is a door, not a - poster. */} - - - -
- {deltaShown && digest.score?.delta != null ? ( - - {formatDelta(digest.score.delta, t)} - - ) : null} - {steadyLine ? ( - - {steadyLine} - - ) : null} - {basisLine ? ( - - {basisLine} - - ) : null} -
- ); - }; + + +
+ ); + + /* The score's meta lines: its delta, how long it has held, and what it + rests on. On the phone's full-width row the basis sentence keeps one + line; beside a dial it wraps at the dial column's width. */ + const scoreMetaLines = (size: "sm" | "responsive") => ( + <> + {deltaShown && digest.score?.delta != null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} + {basisLine ? ( + + {basisLine} + + ) : null} + + ); + + /* Health score ring — `flat` (no sweep/bloom), server-computed band. A + null score paints the ring's honest provisional face at the same + footprint, so the column never collapses. The compact all-clear keeps + the ring and its meta lines in one centred column. */ + const compactScoreColumn = ( +
+ {scoreRing("sm")} + {scoreMetaLines("sm")} +
+ ); const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: @@ -460,7 +461,7 @@ export function TodayHero({ second row below the dial. */} {sleepPendingNote} - {scoreColumn("sm")} + {compactScoreColumn} ) : ( /* The day's read: the narrative lead on the reading edge, the @@ -496,20 +497,43 @@ export function TodayHero({ className={cn( "min-w-0", leadBlock - ? "col-span-2 row-start-2 md:col-span-1 md:col-start-1" + ? cn( + "col-span-2 md:col-span-1 md:col-start-1 md:row-start-2", + hasScoreMeta ? "row-start-3" : "row-start-2", + ) : "col-start-1 row-start-1", )} > {todayBlock} ) : null} + {/* One column from md up (the ring, its meta lines under it, + trailing edge), as before. Below md the column dissolves + (`contents`) into two grid items: the dial in the top-right + cell beside the lead, and the meta lines on a full-width row + right after the read row, flush with the dial's edge: under + the dial beside a short lead, under a long lead's last line. + In the dial's 80 px column the German lines broke into three + to five rows each and pushed the facts down. */}
- {scoreColumn("responsive")} + {scoreRing( + "responsive", + "col-start-2 row-start-1 self-start md:self-auto", + )} + {hasScoreMeta ? ( +
+ {scoreMetaLines("responsive")} +
+ ) : null}
)}