fix(insights): match HealthScore card height to the hero column

The HealthScore card paints 75-110 px shorter than the action+prompts
column to its left at md+ — the bottom edge of the card lands a full
chip-row above the suggested-prompts strip's last entry. The two
columns sit in a flex row but the row was anchoring children with
`items-start`, which lets the right column collapse to its content
height even though the left column's chip-strip wraps to two or three
rows on desktop.

The fix is two-sided:

- The hero row flips from `items-start` to `items-stretch` whenever the
  HealthScore panel mounts. The class only applies on the conditional
  branch so the no-score layout keeps the same vertical rhythm it had.

- The card wrapper opts into the stretched shell with `flex h-full
  flex-col`; the existing inner content block becomes `flex-1 flex
  flex-col gap-3` and the disclaimer footer gets `mt-auto`. The score
  number and sub-bars stay anchored at the top, the disclaimer pins to
  the bottom, and the recovered vertical space sits quietly between
  the provenance accordion and the disclaimer footer rather than
  spilling under the disclaimer line.

FB-H3 — the "Wochenbericht erstellen" button and the WeeklyReportBanner
were both retired in the R3b weekly-report scope-reduction pass; the
hero action row is already Coach-only and nothing in this commit
revisits that surface.

Tests pin the row's `md:items-stretch` / `lg:items-stretch` classes,
the wrapper's `h-full flex flex-col` opt-in, and the disclaimer's
`mt-auto` anchor — DOM-class assertions, no brittle pixel-height
checks. Tests on the no-score branch keep the older `items-start`
absence pinned so a future refactor can't blanket-apply the stretch
contract on layouts that have nothing to stretch toward.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:41:36 +02:00
1 parent 6f6992c683
commit 155b529def
4 files changed
+111 -4

No files matched your search

@@ -260,6 +260,50 @@ describe("<HealthScoreCard>", () => {
expect(progressMatch?.[1]).toBe("100%");
});
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
it("opts the wrapper into the parent's flex-stretch row via h-full + flex-col", () => {
// The hero strip's row now uses `items-stretch`; the card has to
// declare `h-full flex flex-col` so the stretched shell actually
// affects its rendered height. Pin both classes so a refactor
// can't silently drop one.
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
const cardOpen = html.match(
/<div[^>]*data-slot="health-score-card"[^>]*>/,
);
expect(cardOpen).not.toBeNull();
expect(cardOpen?.[0]).toContain("h-full");
expect(cardOpen?.[0]).toContain("flex");
expect(cardOpen?.[0]).toContain("flex-col");
});
it("pins the disclaimer to the bottom of the stretched card via mt-auto", () => {
// Without `mt-auto` the disclaimer rides directly under the
// provenance accordion and leaves the recovered space below the
// disclaimer instead of between the body and the footer. The
// FB-H2 contract is "score number on top, disclaimer on bottom,
// recovered space in the middle".
const html = ssr(
<HealthScoreCard
score={86}
band="green"
components={baseComponents}
delta={null}
/>,
);
const disclaimerOpen = html.match(
/<p[^>]*data-slot="health-score-card-disclaimer"[^>]*>/,
);
expect(disclaimerOpen).not.toBeNull();
expect(disclaimerOpen?.[0]).toContain("mt-auto");
});
it("exposes the progressbar role + ARIA values for screen readers", () => {
const html = ssr(
<HealthScoreCard
@@ -279,4 +279,42 @@ describe("<HeroStrip>", () => {
// is present so the panel sits beside the title block on desktop.
expect(html).toContain("lg:flex-row");
});
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
it("stretches the row's cross-axis when the HealthScore card mounts (md+/lg+)", () => {
// Per Inv-4 the right column painted 75-110 px shorter than the
// left column on desktop. Switching the parent flex row from
// `items-start` to `items-stretch` gives the card a stretched
// shell to grow into. The class is load-bearing for FB-H1/H2 —
// if a future refactor reverts to `items-start` the height gap
// returns.
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: null,
}}
/>,
);
expect(html).toContain("md:items-stretch");
expect(html).toContain("lg:items-stretch");
expect(html).not.toContain("md:items-start");
});
it("does NOT stretch the row when healthScore is omitted", () => {
// Pin the negative case so a future refactor can't blanket-apply
// the stretch contract on the no-score layout (no right column
// means nothing to stretch toward).
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain("md:items-stretch");
});
});
+16 -3
View File
@@ -241,9 +241,16 @@ export function HealthScoreCard({
// (~36 % at md, ~40 % at xl) but cede headroom when the parent
// narrows.
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
// v1.4.28 R3c-Insights — stretch to match the parent's
// `items-stretch` row height (FB-H1/H2). `h-full` + an inner
// flex column lets the disclaimer ride to the bottom with
// `mt-auto` so the headline number stays at the top and the
// recovered vertical space pads through the middle rather
// than under the disclaimer.
"flex h-full flex-col",
)}
>
<div className="flex flex-col gap-3">
<div className="flex flex-1 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<p
@@ -545,10 +552,16 @@ export function HealthScoreCard({
carries an "Ask the coach" action so the inline button
here retires; the parent still receives onAskCoach via
other surfaces but the card no longer mounts a duplicate
CTA. */}
CTA.
v1.4.28 R3c-Insights — `mt-auto` pins the disclaimer to
the bottom of the stretched card so the score number and
sub-bars stay anchored at the top while the recovered
vertical space (when the parent column is taller) sits
quietly between the provenance accordion and the
disclaimer footer. */}
<p
data-slot="health-score-card-disclaimer"
className="text-muted-foreground text-[11px] leading-snug"
className="text-muted-foreground mt-auto text-[11px] leading-snug"
>
{t("insights.healthScore.disclaimer")}
</p>
+13 -1
View File
@@ -151,11 +151,23 @@ export function HeroStrip({
* `lg:` modifiers alongside so existing snapshot/assertion tests
* that grep for `lg:flex-row` continue to find it.
*/}
{/*
* v1.4.28 R3c-Insights — switch the row's cross-axis alignment
* from `items-start` to `items-stretch` so the right-column
* HealthScore card grows to match the left column's natural
* height (greeting + subtitle + baseline meta + action row +
* suggested-prompts strip). Per Inv-4 the card was painting
* 75-110 px shorter than the left column on desktop; the
* stretch contract pulls the card's bottom edge down to the
* "Wirkt mein Medikament?" chip. The card itself owns
* `h-full` + `mt-auto` on its disclaimer to redistribute the
* recovered space without visual jank.
*/}
<div
className={cn(
"flex flex-col gap-5",
healthScore &&
"md:flex-row md:items-start md:gap-6 lg:flex-row lg:items-start lg:gap-6",
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">