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