fix(dashboard): flatten the hero band to match the tile + chart surfaces

The daily-verdict band wore a gradient surface and a health-score ring
with a drop-shadow bloom, a green-band breathing pulse, a conic sheen,
and a sweep entrance. It read louder than the chart cards and tiles it
sits among.

Drop the gradient: the band now wears the same bg-card + border + radius
+ padding as <TrendCard>, and the dose chip sits on a neutral muted
surface (no card tint, blur, or shadow). Add a flat treatment to
<ScoreRing> that suppresses the bloom, pulse, sheen, and entrance sweep,
and pass it on the hero ring only. The wellness strip and anatomy view
keep the full reveal. Reduced-motion handling is unchanged.

Information is untouched — only the visual treatment changes.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:54:25 +02:00
1 parent a7b2b61b99
commit 651aa65c28
4 files changed
+51 -23

No files matched your search

+5
View File
@@ -705,6 +705,11 @@
0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)
);
}
/* Flat 2-D arc — no bloom, no pulse. The dashboard hero ring uses this so it
reads as calmly as the chart + tile surfaces around it. */
.wellness-ring-arc--flat {
filter: none;
}
.wellness-ring-arc--pulse {
/* Breathe a few times to celebrate a strong score, then settle — an
infinite loop would repaint the (non-compositable) drop-shadow filter
@@ -361,7 +361,7 @@ describe("<DashboardHero> — verdict variants", () => {
});
describe("<DashboardHero> — dose row", () => {
it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => {
it("prints the day tally with the neutral chip chrome and the Pill icon", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }),
@@ -372,15 +372,13 @@ describe("<DashboardHero> — dose row", () => {
/<div[^>]*data-slot="dashboard-hero-doses"[^>]*>/,
);
expect(doseRow).not.toBeNull();
for (const cls of [
"bg-card/65",
"border-border/60",
"rounded-xl",
"shadow-sm",
"backdrop-blur-sm",
]) {
// v1.18.1: chip sits on a neutral muted surface over the plain card —
// no translucent card tint, no blur, no shadow (the gradient is gone).
for (const cls of ["bg-muted/50", "border-border/60", "rounded-xl"]) {
expect(doseRow![0]).toContain(cls);
}
expect(doseRow![0]).not.toContain("backdrop-blur");
expect(doseRow![0]).not.toContain("shadow-sm");
});
it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => {
@@ -581,8 +579,17 @@ describe("<DashboardHero> — source pins", () => {
expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/);
});
it("uses the hero-gradient chrome WITHOUT glow-purple", () => {
expect(src).toContain("hero-gradient");
it("wears the plain card surface — no gradient, no glow", () => {
// v1.18.1: the band drops the gradient hero treatment and sits on the
// same `bg-card` + border + radius as the surrounding chart cards.
expect(src).toContain("bg-card");
expect(src).toContain("border-border");
expect(src).toContain("rounded-xl");
expect(src).not.toContain("hero-gradient");
expect(src).not.toContain("glow-purple");
});
it("renders the score ring flat (no bloom / pulse / sheen / sweep)", () => {
expect(src).toMatch(/<ScoreRing[^>]*\n(?:[^>]*\n)*?\s*flat\b/);
});
});
@@ -15,8 +15,10 @@
* band's anchor, larger and heavier than the verdict so the
* personalised line is not drowned by the content below it), the
* verdict sentence with its single CTA button, and the dose row;
* - right column: the shared `<ScoreRing>` at `sm` geometry. A null
* score renders the ring's provisional state at the identical
* - right column: the shared `<ScoreRing>` at `sm` geometry, in its
* `flat` treatment (no bloom / pulse / sheen / sweep) so the dial
* sits as calmly as the surrounding chart cards. A null score
* renders the ring's provisional state at the identical
* 120 px footprint, so the column never collapses. The provisional
* label is honest about WHY the score is null: when the snapshot
* already carries score inputs (weight / BP summaries, mood
@@ -220,11 +222,12 @@ export function DashboardHero({
<section
data-slot="dashboard-hero"
className={cn(
// `.hero-gradient` carries its own border tint; deliberately
// no glow treatment — the band sits above the tile strip and
// must read calmer than the /insights hero.
"hero-gradient relative isolate overflow-hidden rounded-xl",
"min-h-[8.75rem] px-4 py-5 sm:px-6 md:min-h-[9.5rem]",
// The band sits in the same column as the chart cards + tile
// strip, so it wears the same surface: plain `bg-card` with the
// shared border + radius + padding (mirrors `<TrendCard>`). No
// gradient, no glow — it reads as one of the dashboard tiles.
"bg-card border-border relative isolate overflow-hidden rounded-xl border",
"min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6",
)}
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -272,7 +275,7 @@ export function DashboardHero({
</div>
<div
data-slot="dashboard-hero-doses"
className="bg-card/65 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2 shadow-sm backdrop-blur-sm"
className="bg-muted/50 border-border/60 inline-flex max-w-full items-center gap-2 rounded-xl border px-3 py-2"
>
<Pill
className="text-muted-foreground h-4 w-4 shrink-0"
@@ -302,6 +305,7 @@ export function DashboardHero({
score={snapshot.healthScore?.score ?? null}
band={snapshot.healthScore?.band}
size="sm"
flat
label={
snapshot.healthScore
? t("dashboard.hero.scoreLabel")
+17 -5
View File
@@ -77,6 +77,13 @@ export interface ScoreRingProps {
* rings reveals left-to-right and each number trails its own arc.
*/
delayMs?: number;
/**
* Flat 2-D treatment: drops the drop-shadow bloom, the breathing pulse, the
* conic sheen, and the sweep/count-up entrance. Used on the dashboard hero so
* the ring sits as calmly as the surrounding chart + tile surfaces. The
* /insights wellness strip + anatomy view keep the full premium reveal.
*/
flat?: boolean;
/**
* Optional "your normal" reference (0..100). When set + finite, a faint
* thinner ghost arc renders under the live arc so the user sees today vs
@@ -102,6 +109,7 @@ export function ScoreRing({
hue,
delayMs = 0,
baseline,
flat = false,
className,
}: ScoreRingProps) {
const { t } = useTranslations();
@@ -119,7 +127,9 @@ export function ScoreRing({
const glow = RING_GLOW[hueKey];
const reduced = prefersReducedMotion();
const shouldAnimate = animate && hasScore && !reduced;
// `flat` forces the calm static treatment regardless of the `animate` prop:
// no sweep, no count-up, no pulse, no sheen, no bloom.
const shouldAnimate = animate && hasScore && !reduced && !flat;
const dash = (clamped / 100) * C;
const hasBaseline = baseline != null && Number.isFinite(baseline) && hasScore;
@@ -220,8 +230,9 @@ export function ScoreRing({
{/* The progress arc — single-hue gradient, round cap, spring sweep, bloom. */}
<circle
className={cn(
"wellness-ring-arc",
shouldAnimate &&
flat ? "wellness-ring-arc--flat" : "wellness-ring-arc",
!flat &&
shouldAnimate &&
resolvedBand === "green" &&
"wellness-ring-arc--pulse",
)}
@@ -241,8 +252,9 @@ export function ScoreRing({
}}
/>
</svg>
{/* Conic specular sheen — sweeps the arc once on the strip reveal. */}
<div className="wellness-ring-sheen" aria-hidden="true" />
{/* Conic specular sheen — sweeps the arc once on the strip reveal.
Omitted entirely in the flat treatment (dashboard hero). */}
{flat ? null : <div className="wellness-ring-sheen" aria-hidden="true" />}
{/* Centred number as real DOM text — design tokens + tabular-nums. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span