mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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.
This commit is contained in:
1 parent
aa222f5b02
commit
ff0769f045
4 files changed
+441
-203
No files matched your search
@@ -388,7 +388,7 @@ describe("<TodayHero>", () => {
|
||||
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(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
@@ -401,7 +401,8 @@ describe("<TodayHero>", () => {
|
||||
|
||||
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("<TodayHero> Today overview", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the ring beside the lead and the overview under both on a phone", () => {
|
||||
const html = render(
|
||||
<TodayHero digest={digest({ today: FACTS, worthALook: [] })} />,
|
||||
);
|
||||
// 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(/<div class="([^"]*(?:col-|row-)[^"]*)"/g),
|
||||
];
|
||||
return cells.at(-1)?.[1] ?? "";
|
||||
};
|
||||
expect(cellClass("today-hero-lead")).toContain("row-start-1");
|
||||
// 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");
|
||||
});
|
||||
|
||||
it("stacks each fact's label above its value, both on the reading edge", () => {
|
||||
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
|
||||
const links = [
|
||||
...html.matchAll(
|
||||
/data-slot="today-hero-fact"[^>]*><a[^>]*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><span class="text-foreground/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the fifth fact below md and never the first four", () => {
|
||||
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
|
||||
const items = [
|
||||
|
||||
+172
-110
@@ -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. */}
|
||||
<Link
|
||||
href={fact.href}
|
||||
className="focus-visible:ring-ring/50 hover:bg-muted/50 -mx-1.5 flex min-h-11 items-center justify-between gap-3 rounded-md px-1.5 py-1 focus-visible:ring-2 focus-visible:outline-none md:min-h-0 md:flex-col md:items-start md:justify-start md:gap-0.5"
|
||||
className="focus-visible:ring-ring/50 hover:bg-muted/50 -mx-1.5 flex min-h-11 flex-col justify-center gap-0.5 rounded-md px-1.5 py-1 focus-visible:ring-2 focus-visible:outline-none md:min-h-0 md:justify-start"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 text-sm md:text-xs">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fact.label}
|
||||
</span>
|
||||
<span className="text-foreground min-w-0 text-right text-sm font-medium md:text-left">
|
||||
<span className="text-foreground min-w-0 text-sm font-medium">
|
||||
{fact.value}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -285,6 +290,109 @@ export function TodayHero({
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="space-y-2">
|
||||
{lead ? (
|
||||
<div
|
||||
data-slot="today-hero-lead"
|
||||
data-source={digest.lead?.source}
|
||||
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
<ProseBlocks text={lead} strip linkify={false} />
|
||||
</div>
|
||||
) : null}
|
||||
{signalLine ? (
|
||||
<p
|
||||
data-slot="today-hero-signal"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{signalLine.headline}
|
||||
{signalLine.headline && signalLine.delta ? " · " : null}
|
||||
{signalLine.delta}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col gap-1 md:items-end",
|
||||
// The phone dial sits flush with the card's trailing edge, mirroring
|
||||
// the lead's leading one. Its meta lines align under it and are no
|
||||
// wider than the dial, so the lead keeps the width beside it.
|
||||
size === "responsive"
|
||||
? "items-end text-right"
|
||||
: "items-center text-center",
|
||||
)}
|
||||
>
|
||||
<div data-slot="today-hero-score">
|
||||
{/* The ring opens the Insights overview — the destination the
|
||||
health-score card owns — so every hero ring is a door, not a
|
||||
poster. */}
|
||||
<Link
|
||||
href="/insights"
|
||||
aria-label={t("daily.today.ringLink", {
|
||||
metric: t("daily.today.scoreLabel"),
|
||||
})}
|
||||
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ScoreRing
|
||||
score={digest.score?.value ?? null}
|
||||
band={digest.score ? (digest.score.band as ScoreBand) : undefined}
|
||||
size={size}
|
||||
flat
|
||||
label={t("daily.today.scoreLabel")}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{deltaShown && digest.score?.delta != null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs tabular-nums",
|
||||
phoneMeta,
|
||||
)}
|
||||
>
|
||||
{formatDelta(digest.score.delta, t)}
|
||||
</span>
|
||||
) : null}
|
||||
{steadyLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-steady"
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs text-balance",
|
||||
phoneMeta,
|
||||
)}
|
||||
>
|
||||
{steadyLine}
|
||||
</span>
|
||||
) : null}
|
||||
{basisLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-basis"
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs text-balance md:max-w-[11rem] md:text-right",
|
||||
size === "responsive" ? "max-w-20" : "max-w-[11rem]",
|
||||
)}
|
||||
>
|
||||
{basisLine}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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. */}
|
||||
<div className="flex flex-col gap-3 md:gap-4">
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4",
|
||||
compactAllClear
|
||||
? "flex-row items-center justify-between"
|
||||
: "flex-col md:flex-row md:items-start md:justify-between md:gap-6",
|
||||
)}
|
||||
>
|
||||
{/* The overview is its own section under the lead, so it keeps a
|
||||
section's distance from it; the compact all-clear stays tight. */}
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
compactAllClear ? "space-y-3" : "space-y-4 md:space-y-6",
|
||||
)}
|
||||
>
|
||||
{/* Hero numeric face: the read leads large in the foreground
|
||||
token, calm and legible — the day's read, not a slogan. */}
|
||||
{lead || signalLine ? (
|
||||
<div className="space-y-2">
|
||||
{lead ? (
|
||||
<div
|
||||
data-slot="today-hero-lead"
|
||||
data-source={digest.lead?.source}
|
||||
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
<ProseBlocks text={lead} strip linkify={false} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* Top signal — present-tense headline + optional delta, one
|
||||
muted step down so it supports the lead without
|
||||
competing. */}
|
||||
{signalLine ? (
|
||||
<p
|
||||
data-slot="today-hero-signal"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{signalLine.headline}
|
||||
{signalLine.headline && signalLine.delta ? " · " : null}
|
||||
{signalLine.delta}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : 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. */
|
||||
<div className="flex flex-row items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("daily.today.allClear")}
|
||||
</p>
|
||||
) : 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}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex shrink-0 flex-col items-center gap-1 md:items-end">
|
||||
<div data-slot="today-hero-score">
|
||||
{/* 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. */}
|
||||
<Link
|
||||
href="/insights"
|
||||
aria-label={t("daily.today.ringLink", {
|
||||
metric: t("daily.today.scoreLabel"),
|
||||
})}
|
||||
className="focus-visible:ring-ring/50 block rounded-full focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ScoreRing
|
||||
score={digest.score?.value ?? null}
|
||||
band={
|
||||
digest.score ? (digest.score.band as ScoreBand) : undefined
|
||||
}
|
||||
size={compactAllClear ? "sm" : "md"}
|
||||
flat
|
||||
label={t("daily.today.scoreLabel")}
|
||||
/>
|
||||
</Link>
|
||||
{/* 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}
|
||||
</div>
|
||||
{deltaShown && digest.score?.delta != null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{formatDelta(digest.score.delta, t)}
|
||||
</span>
|
||||
) : null}
|
||||
{steadyLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-steady"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{steadyLine}
|
||||
</span>
|
||||
) : null}
|
||||
{basisLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-basis"
|
||||
className="text-muted-foreground max-w-[11rem] text-center text-xs text-balance md:text-right"
|
||||
>
|
||||
{basisLine}
|
||||
</span>
|
||||
) : null}
|
||||
{scoreColumn("sm")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 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. */
|
||||
<div
|
||||
data-slot="today-hero-read"
|
||||
className={cn(
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-4 md:gap-x-6 md:gap-y-6",
|
||||
// A ring taller than the lead pushes its extra height into the
|
||||
// second row only, so the overview stays right under the lead.
|
||||
leadBlock && todayBlock && "md:grid-rows-[auto_1fr]",
|
||||
)}
|
||||
>
|
||||
{leadBlock ? (
|
||||
<div className="col-start-1 row-start-1 min-w-0 self-start">
|
||||
{leadBlock}
|
||||
</div>
|
||||
) : null}
|
||||
{todayBlock ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
leadBlock
|
||||
? "col-span-2 row-start-2 md:col-span-1 md:col-start-1"
|
||||
: "col-start-1 row-start-1",
|
||||
)}
|
||||
>
|
||||
{todayBlock}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"col-start-2 row-start-1 self-start",
|
||||
leadBlock && todayBlock && "md:row-span-2",
|
||||
)}
|
||||
>
|
||||
{scoreColumn("responsive")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
|
||||
text-muted-foreground` is the meta floor; never an accent, never an
|
||||
|
||||
@@ -54,11 +54,27 @@ const SPRING_SAFE_MAX = 94;
|
||||
|
||||
const SIZE: Record<
|
||||
NonNullable<ScoreRingProps["size"]>,
|
||||
{ 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
|
||||
|
||||
Reference in new issue
Block a user