From ff0769f045e0587d00ead7027773ea57ceb53c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:30:29 +0200 Subject: [PATCH] fix(dashboard): lay the Today hero out for a phone Below md the score ring sat centred under the Today facts as a 168 px dial, where the bottom navigation covered it. The hero is now one grid with two arrangements: on a phone the ring is a compact 80 px dial at the top right beside the lead and signal line, flush with the card's trailing edge, and the Today facts run the full card width under both; from md up the ring spans both rows of the trailing column exactly as before (desktop captures are pixel-identical). Without a lead the facts take the leading cell, so the ring never sits beside an empty row. ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is left out below md; the ring keeps its aria-label and the surrounding link names it. The meta lines under the phone dial are capped to its width, so the lead keeps at least 180 px beside it at 360 px. Each fact now stacks its muted label above its foreground value on the reading edge at every width, instead of a label-left / value-right row that broke long values into a ragged right-aligned block. Rows stay at the 44 px tap floor below md. The Today overview e2e covers 1440, 390 and 360 px in both themes for an AI lead, a long AI lead, an AI-off day and a quiet day, and asserts the dial size, its place beside the lead, symmetric insets, full-width facts and stacked 44 px rows on a phone. --- e2e/today-overview.spec.ts | 277 +++++++++++------ .../daily/__tests__/today-hero.test.tsx | 48 ++- src/components/daily/today-hero.tsx | 282 +++++++++++------- .../insights/derived/score-ring.tsx | 37 ++- 4 files changed, 441 insertions(+), 203 deletions(-) diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 459201129..4216f5b82 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -16,11 +16,13 @@ import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures" * The Today overview on the dashboard hero, at a desktop and a phone width. * * What it proves is geometry, because that is what the unit suites cannot - * see: the facts sit beside the ring on a wide screen and above it on a - * phone, a phone shows four facts and a wide screen five, nothing pushes the - * page sideways, the hero keeps the width of the cards around it, and the - * ring keeps its size. Each state is also captured as an element screenshot - * under `test-results/today-overview/` for a visual check. + * see: the facts sit beside the ring on a wide screen; on a phone the ring is + * a compact dial at the top right beside the lead and the facts run the full + * card width under both; a phone shows four facts and a wide screen five, + * nothing pushes the page sideways, the hero keeps the width of the cards + * around it, and the ring keeps its size per breakpoint. Each state is also + * captured as an element screenshot, in both themes, under + * `test-results/today-overview/` for a visual check. * * The digests are what the server would publish for each state. They carry * the pre-overview fields too (`briefingLead`, `line`), with the values the @@ -36,8 +38,17 @@ const SHOTS = join(process.cwd(), "test-results", "today-overview"); const VIEWPORTS = [ { name: "desktop", width: 1440, height: 900 }, { name: "mobile", width: 390, height: 844 }, + { name: "narrow", width: 360, height: 780 }, ] as const; +const THEMES = ["dark", "light"] as const; + +/** The dial's edge per breakpoint: compact on a phone, md from 768 px. */ +const RING_PX = { phone: 80, wide: 168 } as const; + +/** Below this the lead beside the ring would read as a column of words. */ +const MIN_LEAD_WIDTH = 180; + const BASE: DailyDigest = { generatedAt: new Date().toISOString(), ai: DIGEST_AI_AVAILABLE, @@ -158,6 +169,39 @@ const STATES: Record = { }, today: [FACTS[0], FACTS[2], FACTS[4]], }, + // AI on with a long lead and a long sleep value: the phone case the ring + // used to fall under the facts for. + "ai-on-long": { + ...BASE, + score: { + value: 63, + band: "yellow", + delta: -6, + deltaReason: null, + steadyWeeks: null, + }, + lead: { + text: "Your pulse sits well above its usual level this evening, after a shorter night than you normally get; a quieter evening would suit it.", + source: "briefing", + }, + signalLine: { headline: null, delta: "+34 bpm vs your 30-day average" }, + today: [ + FACTS[0], + { + kind: "sleep", + label: "Last night", + value: "5h 45m, 42m less than usual", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitals", + value: "Pulse above your range", + href: "/insights", + }, + ], + }, // A quiet day: one calm sentence and no facts beyond it. quiet: { ...BASE, @@ -195,96 +239,163 @@ test.describe("Today overview geometry", () => { }); for (const viewport of VIEWPORTS) { - for (const [state, digest] of Object.entries(STATES)) { - test(`${state} at ${viewport.width}px`, async ({ page }) => { - await page.setViewportSize(viewport); - const hero = await openDashboard(page, digest); + for (const theme of THEMES) { + for (const [state, digest] of Object.entries(STATES)) { + test(`${state} at ${viewport.width}px, ${theme}`, async ({ page }) => { + // The app paints from its own stored preference, not the media + // query, so both are set. + await page.emulateMedia({ + colorScheme: theme, + reducedMotion: "reduce", + }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + const phone = viewport.width < 768; - mkdirSync(SHOTS, { recursive: true }); - await hero.screenshot({ - path: join(SHOTS, `${viewport.name}-${state}.png`), - }); + // Late banners (the key-backup nudge) insert above the hero; let + // the page settle so the capture shows one stable layout. + await page.waitForLoadState("networkidle"); + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-${theme}-${state}.png`), + }); + // The viewport as a reader sees it, bottom navigation included. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: join( + SHOTS, + `${viewport.name}-${theme}-${state}-viewport.png`, + ), + }); - // Nothing pushes the page sideways. - const overflow = await page.evaluate( - () => document.documentElement.scrollWidth - window.innerWidth, - ); - expect(overflow).toBeLessThanOrEqual(0); + // Nothing pushes the page sideways. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); - // The hero spans the same column as the tile strip below it. - const heroBox = (await hero.boundingBox())!; - const stripBox = (await page - .locator('[data-slot="dashboard-tile-strip"]') - .boundingBox())!; - expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1); - expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(1); + // The hero spans the same column as the tile strip below it. + const heroBox = (await hero.boundingBox())!; + const stripBox = (await page + .locator('[data-slot="dashboard-tile-strip"]') + .boundingBox())!; + expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1); + expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual( + 1, + ); - // The ring keeps its size: the same 168 px dial as before. - const ring = hero.locator('[data-slot="score-ring"]').first(); - const ringBox = (await ring.boundingBox())!; - expect(Math.round(ringBox.width)).toBe(168); - expect(Math.round(ringBox.height)).toBe(168); + // The ring keeps its size: a compact dial on a phone, the same + // 168 px dial as before from md up. + const ring = hero.locator('[data-slot="score-ring"]').first(); + const ringBox = (await ring.boundingBox())!; + const edge = phone ? RING_PX.phone : RING_PX.wide; + expect(Math.round(ringBox.width)).toBe(edge); + expect(Math.round(ringBox.height)).toBe(edge); - const lead = hero.locator('[data-slot="today-hero-lead"]'); - await expect(lead).toBeVisible(); - await expect(lead).toHaveAttribute("data-source", digest.lead!.source); - const leadBox = (await lead.boundingBox())!; + const lead = hero.locator('[data-slot="today-hero-lead"]'); + await expect(lead).toBeVisible(); + await expect(lead).toHaveAttribute( + "data-source", + digest.lead!.source, + ); + const leadBox = (await lead.boundingBox())!; - const facts = hero.locator('[data-slot="today-hero-fact"]'); - const expected = digest.today.length; - await expect(facts).toHaveCount(expected); - const visible = await facts.evaluateAll( - (items) => - items.filter((el) => (el as HTMLElement).offsetParent !== null) - .length, - ); - expect(visible).toBe( - viewport.width < 768 ? Math.min(expected, 4) : expected, - ); + // The ring sits at the top right, beside the lead, and the lead + // keeps a readable width next to it. + expect(ringBox.x).toBeGreaterThanOrEqual(leadBox.x + leadBox.width); + expect(ringBox.y).toBeLessThan(leadBox.y + leadBox.height); + expect(Math.abs(ringBox.y - leadBox.y)).toBeLessThanOrEqual(8); + expect(leadBox.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH); + // Symmetric insets: the ring's right edge mirrors the lead's left. + const leftInset = leadBox.x - heroBox.x; + const rightInset = + heroBox.x + heroBox.width - (ringBox.x + ringBox.width); + expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1); - // The all-clear sentence never sits under a lead or facts. - await expect( - hero.locator('[data-slot="today-hero-all-clear"]'), - ).toHaveCount(0); + const facts = hero.locator('[data-slot="today-hero-fact"]'); + const expected = digest.today.length; + await expect(facts).toHaveCount(expected); + const visible = await facts.evaluateAll( + (items) => + items.filter((el) => (el as HTMLElement).offsetParent !== null) + .length, + ); + expect(visible).toBe(phone ? Math.min(expected, 4) : expected); - if (expected === 0) { + // The all-clear sentence never sits under a lead or facts. await expect( - hero.locator('[data-slot="today-hero-today"]'), + hero.locator('[data-slot="today-hero-all-clear"]'), ).toHaveCount(0); - // A quiet day keeps the full composition: lead beside the ring. - await expect(hero).toHaveAttribute("data-layout", "narrative"); - return; - } - const block = hero.locator('[data-slot="today-hero-today"]'); - const blockBox = (await block.boundingBox())!; - // The facts follow the lead. - expect(blockBox.y).toBeGreaterThanOrEqual( - leadBox.y + leadBox.height - 1, - ); - if (viewport.width >= 768) { - // Beside the ring on a wide screen, never under it. - expect(blockBox.x + blockBox.width).toBeLessThanOrEqual( - ringBox.x + 1, - ); - expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height); - } else { - // Above the ring on a phone, so the day reads before the dial. - expect(blockBox.y + blockBox.height).toBeLessThanOrEqual( - ringBox.y + 1, - ); - } + if (expected === 0) { + await expect( + hero.locator('[data-slot="today-hero-today"]'), + ).toHaveCount(0); + // A quiet day keeps the full composition: lead beside the ring. + await expect(hero).toHaveAttribute("data-layout", "narrative"); + return; + } - // No fact line overflows its own box. - const clipped = await facts.evaluateAll( - (items) => - items.filter((el) => { - const link = el.querySelector("a"); - return link ? link.scrollWidth > link.clientWidth + 1 : false; - }).length, - ); - expect(clipped).toBe(0); - }); + const block = hero.locator('[data-slot="today-hero-today"]'); + const blockBox = (await block.boundingBox())!; + // The facts follow the lead. + expect(blockBox.y).toBeGreaterThanOrEqual( + leadBox.y + leadBox.height - 1, + ); + if (!phone) { + // Beside the ring on a wide screen, never under it. + expect(blockBox.x + blockBox.width).toBeLessThanOrEqual( + ringBox.x + 1, + ); + expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height); + } else { + // Under the dial on a phone, across the full card width. + expect(blockBox.y).toBeGreaterThanOrEqual( + ringBox.y + ringBox.height - 1, + ); + expect( + Math.abs( + blockBox.x + blockBox.width - (ringBox.x + ringBox.width), + ), + ).toBeLessThanOrEqual(1); + // Each tappable fact keeps the 44 px floor, label over value, + // both on the reading edge. + const rows = await facts.evaluateAll((items) => + items + .filter((el) => (el as HTMLElement).offsetParent !== null) + .map((el) => { + const link = el.querySelector("a")!; + const [label, value] = Array.from(link.children).map((c) => + c.getBoundingClientRect(), + ); + return { + height: link.getBoundingClientRect().height, + stacked: value.top >= label.bottom - 1, + sameEdge: Math.abs(value.left - label.left) <= 1, + }; + }), + ); + for (const row of rows) { + expect(row.height).toBeGreaterThanOrEqual(44); + expect(row.stacked).toBe(true); + expect(row.sameEdge).toBe(true); + } + } + + // No fact line overflows its own box. + const clipped = await facts.evaluateAll( + (items) => + items.filter((el) => { + const link = el.querySelector("a"); + return link ? link.scrollWidth > link.clientWidth + 1 : false; + }).length, + ); + expect(clipped).toBe(0); + }); + } } } diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index c0939590f..d8d8640cf 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -388,7 +388,7 @@ describe("", () => { expect(text.match(/\b82\b/g)).toHaveLength(1); }); - it("keeps the full narrative composition and md score ring when a lead exists", () => { + it("keeps the full narrative composition and the responsive score ring when a lead exists", () => { const html = render( ", () => { expect(html).toContain('data-layout="narrative"'); expect(html).toContain('data-slot="today-hero-lead"'); - expect(html).toContain('style="width:168px;height:168px"'); + // A compact dial on a phone, the md dial (168 px) from md up. + expect(html).toContain("size-20 md:size-42"); expect(html).not.toContain('data-layout="compact-all-clear"'); }); @@ -587,6 +588,49 @@ describe(" Today overview", () => { } }); + it("puts the ring beside the lead and the overview under both on a phone", () => { + const html = render( + , + ); + // The read grid: lead cell, overview cell, ring cell, in that order. + const read = html.slice(html.indexOf('data-slot="today-hero-read"')); + // The cell is the grid child wrapping the slot: the nearest preceding + // div that carries a grid placement. + const cellClass = (slot: string) => { + const before = read.slice(0, read.indexOf(`data-slot="${slot}"`)); + const cells = [ + ...before.matchAll(/
{ + const html = render(); + const links = [ + ...html.matchAll( + /data-slot="today-hero-fact"[^>]*>]*class="([^"]*)"[^>]*>(.*?)<\/a>/g, + ), + ]; + expect(links).toHaveLength(FACTS.length); + for (const [, linkClass, inner] of links) { + expect(linkClass).toContain("flex-col"); + expect(linkClass).not.toContain("justify-between"); + expect(linkClass).toContain("min-h-11"); + expect(inner).not.toContain("text-right"); + // Label muted, value foreground. + expect(inner).toMatch( + /text-muted-foreground[^"]*"[^>]*>[^<]+<\/span> { const html = render(); const items = [ diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 53b3f9f59..65bbd4f4e 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -268,14 +268,19 @@ export function TodayHero({ i >= TODAY_FACTS_NARROW && "hidden md:block", )} > + {/* Label above value at every width, both on the reading edge. + A label-left / value-right row pushed a long value ("5 h 45 + min, 42 min less than usual") into a ragged right-aligned + block on a phone; stacked, every value reads as one left- + aligned sentence. The row keeps the 44 px tap floor below md. */} - + {fact.label} - + {fact.value} @@ -285,6 +290,109 @@ export function TodayHero({
) : null; + /* The lead — the read leads large in the foreground token, calm and + legible — and, one muted step down, the top signal's present-tense + headline + delta, supporting it without competing. */ + const leadBlock = + lead || signalLine ? ( +
+ {lead ? ( +
+ +
+ ) : null} + {signalLine ? ( +

+ {signalLine.headline} + {signalLine.headline && signalLine.delta ? " · " : null} + {signalLine.delta} +

+ ) : null} +
+ ) : 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 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. */} + + + +
+ {deltaShown && digest.score?.delta != null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} + {basisLine ? ( + + {basisLine} + + ) : null} +
+ ); + }; + const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: // `.today-hero-wash` leans a faint `--primary` mix over the theme @@ -336,121 +444,75 @@ export function TodayHero({ gaps let the worth-a-look rail sit close under the lead so the card reads filled, not padded out. */}
- {/* The day's read — the numeric face on the trailing edge, the - narrative lead leading. On md+ the lead sits flush-top with the - score ring (items-start) rather than floating centred beside it. */} -
- {/* The overview is its own section under the lead, so it keeps a - section's distance from it; the compact all-clear stays tight. */} -
- {/* Hero numeric face: the read leads large in the foreground - token, calm and legible — the day's read, not a slogan. */} - {lead || signalLine ? ( -
- {lead ? ( -
- -
- ) : null} - {/* Top signal — present-tense headline + optional delta, one - muted step down so it supports the lead without - competing. */} - {signalLine ? ( -

- {signalLine.headline} - {signalLine.headline && signalLine.delta ? " · " : null} - {signalLine.delta} -

- ) : null} -
- ) : null} - {todayBlock} - {compactAllClear ? ( + {compactAllClear ? ( + /* Score-only digest: the all-clear copy leads and a smaller + version of the same ring stays the one numeric face. */ +
+

{t("daily.today.allClear")}

- ) : null} - - {/* In the compact fallback the quiet freshness tier belongs next - to the smaller ring. That uses the otherwise-empty leading - space and avoids adding a second row below the dial. */} - {compactAllClear ? sleepPendingNote : 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 ring opens the Insights overview — the destination the - health-score card owns — matching the cluster rings' tap - behaviour, so every hero ring is a door, not a poster. */} - - - + {/* The quiet freshness tier belongs next to the smaller ring: + it uses the otherwise-empty leading space and adds no + second row below the dial. */} + {sleepPendingNote}
- {deltaShown && digest.score?.delta != null ? ( - - {formatDelta(digest.score.delta, t)} - - ) : null} - {steadyLine ? ( - - {steadyLine} - - ) : null} - {basisLine ? ( - - {basisLine} - - ) : null} + {scoreColumn("sm")}
-
+ ) : ( + /* The day's read: the narrative lead on the reading edge, the + numeric face on the trailing one, the Today overview under the + lead. + + One grid, two arrangements. Below md the ring is a compact dial + at the top right beside the lead, and the overview runs the full + card width underneath both; a phone has no room for a 168 px + dial under the facts, where it used to sit centred and fall + behind the bottom navigation. From md up the ring spans both + rows of the trailing column and the overview stays in the + leading column, flush-top with the ring, exactly as before. + + Without a lead the overview takes the leading cell itself, so + the ring never floats beside an empty row. */ +
+ {leadBlock ? ( +
+ {leadBlock} +
+ ) : null} + {todayBlock ? ( +
+ {todayBlock} +
+ ) : null} +
+ {scoreColumn("responsive")} +
+
+ )} {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs text-muted-foreground` is the meta floor; never an accent, never an diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 917330473..675cff2d6 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -54,11 +54,27 @@ const SPRING_SAFE_MAX = 94; const SIZE: Record< NonNullable, - { px: number; numberClass: string; labelClass: string } + { + /** Fixed edge length; null when `boxClass` sizes the ring per breakpoint. */ + px: number | null; + boxClass?: string; + numberClass: string; + labelClass: string; + } > = { sm: { px: 120, numberClass: "text-3xl", labelClass: "text-2xs" }, md: { px: 168, numberClass: "text-5xl", labelClass: "text-xs" }, lg: { px: 232, numberClass: "text-7xl", labelClass: "text-sm" }, + // The dashboard hero: a compact 80 px dial beside the lead on a phone, the + // `md` dial from the md breakpoint up. Below md the label is left out (the + // number alone is legible at this size; the ring's aria-label and the + // surrounding link still name it), so the centre never crowds. + responsive: { + px: null, + boxClass: "size-20 md:size-42", + numberClass: "text-2xl md:text-5xl", + labelClass: "hidden text-xs md:block", + }, }; export interface ScoreRingProps { @@ -68,8 +84,11 @@ export interface ScoreRingProps { band?: ScoreBand; /** Short label rendered under the number (e.g. "Readiness", "/100"). */ label?: string; - /** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */ - size?: "sm" | "md" | "lg"; + /** + * Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view, + * `responsive` on the dashboard hero (compact on a phone, `md` above). + */ + size?: "sm" | "md" | "lg" | "responsive"; /** Disable the sweep + count-up (e.g. when already revealed this session). */ animate?: boolean; /** @@ -190,12 +209,14 @@ export function ScoreRing({ data-provisional={hasScore ? undefined : "true"} role="img" aria-label={ariaLabel} - className={cn("relative shrink-0", className)} + className={cn("relative shrink-0", dims.boxClass, className)} style={ - { - width: dims.px, - height: dims.px, - } as React.CSSProperties + dims.px === null + ? undefined + : ({ + width: dims.px, + height: dims.px, + } as React.CSSProperties) } > {/* The -90° rotation starts the arc at 12 o'clock so it sweeps clockwise