feat(insights): tighten category-page density and balance the overview hero

Polish the Insights surfaces for a denser, more deliberate rhythm.

Category pages:
- Collapse the double caption under the heading. The explainer
  definition and the description paragraph used to stack, so every
  metric page opened with two near-duplicative muted lines before any
  data. Keep the explainer body as the single caption; render the
  description only when no explainer is set.
- Tighten the header gap (space-y-1.5) and the block rhythm
  (space-y-4 md:space-y-5) so the heading-to-first-data distance
  shrinks by roughly one text block.
- Demote the target summary from a full bordered card to an inline
  band (bg-muted/30 rounded-lg px-3 py-2). One line of range text no
  longer reserves the card primitive's py-4/py-6 shell padding.
- Close the gap under the assessment heading. The card primitive's
  gap-4/gap-6 flex gap floated the title well above its prose; override
  to gap-2 md:gap-3 with py-4/py-5 across every status variant.

Overview hero:
- Condense the "ask the coach" band — denser prompt chips and a
  bottom-pinned strip so the left column stops out-running the
  Health Score card.
- Give the Health Score breakdown more vertical presence (looser row
  rhythm, taller component bars) so the right column fills the row
  height instead of leaving the bottom empty.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 19:54:26 +02:00
1 parent d660279c97
commit 5771b36899
6 files changed
+91 -44

No files matched your search

+10 -4
View File
@@ -265,7 +265,7 @@ export function HealthScoreCard({
the bottom, and the card fills the row down to the
"Wirkt mein Medikament?" chip — closing Marc's "Karte hört
bei der Trennlinie auf" report against v1.4.36. */}
<div className="grid flex-1 grid-rows-[auto_auto_auto_auto_auto_1fr_auto] gap-3">
<div className="grid flex-1 grid-rows-[auto_auto_auto_auto_auto_1fr_auto] gap-3.5">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<p
@@ -382,14 +382,20 @@ export function HealthScoreCard({
<ul
data-slot="health-score-card-components"
className="space-y-1.5 border-t pt-3"
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is
// the natural place to add weight to the Health-Score card so it
// fills the hero-row height instead of leaving the right column
// short and bottom-empty. Loosen the row rhythm (`space-y-2.5` vs
// `space-y-1.5`) and the divider stride (`pt-3.5`) so the four
// component bars read as a richer block; no new data, no new row.
className="space-y-2.5 border-t pt-3.5"
>
{componentEntries.map(({ key, label, value }) => (
<li
key={key}
data-slot="health-score-card-component-row"
data-component={key}
className="flex items-center gap-2 text-[11px]"
className="flex items-center gap-2 text-xs"
>
{/* v1.4.25 W3 — widened label column from w-16 (64px) to
w-24 (96px) so the longest German label
@@ -399,7 +405,7 @@ export function HealthScoreCard({
{label}
</span>
<div
className="bg-muted/50 h-1 flex-1 overflow-hidden rounded-full"
className="bg-muted/50 h-1.5 flex-1 overflow-hidden rounded-full"
aria-hidden="true"
>
<div
+7 -1
View File
@@ -262,7 +262,13 @@ export function HeroStrip({
{coachEnabled && (
<div
data-slot="insights-hero-strip-prompts"
className="border-border/50 border-t pt-4"
// v1.8.5 W4a — condense the "Coach fragen" band so the left
// column stops out-running the Health-Score card on the
// right. Tighter top padding (`pt-3` vs `pt-4`) plus a
// `mt-auto` push pins the chip strip to the bottom of the
// left column, so the recovered slack lands between the meta
// row and the prompts instead of stacking the column tall.
className="border-border/50 mt-auto border-t pt-3"
>
{/*
* v1.4.20 phase B2b — chip clicks open the Coach drawer
@@ -57,6 +57,7 @@ export function InsightStatusCard({
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-preparing"
className="gap-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
@@ -88,7 +89,12 @@ export function InsightStatusCard({
// (`text-sm` → 0.875 rem) so the post-load swap reflows by less
// than a row.
return (
<Card aria-busy="true" aria-live="polite" data-testid="insight-status-card-loading">
<Card
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-loading"
className="gap-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
@@ -108,7 +114,7 @@ export function InsightStatusCard({
if (!hasProvider) {
return (
<Card className="opacity-60">
<Card className="gap-2 py-4 opacity-60 md:gap-3 md:py-5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
@@ -126,7 +132,7 @@ export function InsightStatusCard({
if (!text) {
return (
<Card>
<Card className="gap-2 py-4 md:gap-3 md:py-5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
@@ -150,7 +156,14 @@ export function InsightStatusCard({
// on the success card closes the load→loaded transition signal.
<Card
aria-live="polite"
className="animate-insight-in border-l-dracula-purple border-l-2"
// v1.8.5 W4a — the `Card` primitive ships `gap-4 md:gap-6` as the flex
// gap between header and content, which floated the "Einschätzung"
// heading ~16-24 px above its prose. Override to `gap-2 md:gap-3` (the
// `CardHeader`'s own `pb-2` does not touch the flex gap) and trim the
// shell padding to `py-4 md:py-5` so the assessment block reads tight
// in the denser sub-page rhythm. The same override lands on every
// status variant below so the gap is consistent across states.
className="animate-insight-in border-l-dracula-purple gap-2 border-l-2 py-4 md:gap-3 md:py-5"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@@ -7,7 +7,6 @@ import { Target } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent } from "@/components/ui/card";
/**
* v1.8.0 — surface the numeric target range + in-target share on each
@@ -143,31 +142,39 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
: null;
return (
<Card data-slot="metric-target-summary">
<CardContent className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3">
<div className="flex items-center gap-2.5">
<Target
className="text-dracula-green size-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-0.5">
<p className="text-sm font-medium">{rangeLabel}</p>
{sharePct != null ? (
<p className="text-muted-foreground text-xs">
{t("insights.subPage.target.inTargetShare", {
pct: sharePct,
})}
</p>
) : null}
</div>
// v1.8.5 W4a — demote the target summary from a full bordered `Card`
// (which carried the primitive's `py-4 md:py-6` shell padding around a
// `py-3` content block — far too much vertical reserve for one line of
// range text) to a compact inline band. The green target glyph, the
// range string, the in-target share, and the "adjust" link all stay;
// only the wrapper shell changes so the block reads as a strip, not a
// card, in the tightened sub-page rhythm.
<div
data-slot="metric-target-summary"
className="bg-muted/30 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 rounded-lg px-3 py-2"
>
<div className="flex items-center gap-2.5">
<Target
className="text-dracula-green size-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-0.5">
<p className="text-sm font-medium">{rangeLabel}</p>
{sharePct != null ? (
<p className="text-muted-foreground text-xs">
{t("insights.subPage.target.inTargetShare", {
pct: sharePct,
})}
</p>
) : null}
</div>
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
</CardContent>
</Card>
</div>
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
</div>
);
}
+13 -6
View File
@@ -100,8 +100,8 @@ export function SubPageShell({
}, [focusOnMount]);
return (
<div data-slot="insights-subpage" className="space-y-6">
<header className="space-y-2">
<div data-slot="insights-subpage" className="space-y-4 md:space-y-5">
<header className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1
ref={headingRef}
@@ -129,9 +129,16 @@ export function SubPageShell({
</div>
{explainerMetric ? (
// v1.8.4 — surface the explainer definition inline, reusing the
// exact body string the `?` popover reads. Muted caption styling
// matches the description paragraph below so the under-heading
// band reads as one calm block.
// exact body string the `?` popover reads.
//
// v1.8.5 W4a — render the explainer body as the *single* caption
// under the heading. Pre-v1.8.5 both this paragraph and the
// `description` below it stacked, so every metric page opened
// with two near-duplicative muted captions before any data — the
// root of the "airy / static under the heading" feel. The
// explainer body is the definition, so it wins; the description
// only renders when no explainer is set (the mother page and any
// future explainer-less sub-page).
<p
data-slot="metric-explainer-inline"
className="text-muted-foreground text-sm leading-relaxed"
@@ -139,7 +146,7 @@ export function SubPageShell({
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
</p>
) : null}
{description ? (
{description && !explainerMetric ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
+11 -3
View File
@@ -70,7 +70,10 @@ export function SuggestedPrompts({
return (
<div
data-slot="insights-suggested-prompts"
className={cn("flex flex-wrap items-center gap-2", className)}
// v1.8.5 W4a — tighten the chip row gutter (`gap-1.5` vs `gap-2`) so
// the condensed Coach band reads as a dense, deliberate strip rather
// than an airy wrap. The chips themselves shrink their padding below.
className={cn("flex flex-wrap items-center gap-1.5", className)}
>
<span
data-slot="insights-suggested-prompts-label"
@@ -84,10 +87,15 @@ export function SuggestedPrompts({
type="button"
onClick={() => onPick(prompt)}
data-slot="insights-suggested-prompts-chip"
// v1.8.5 W4a — denser chips. The tap target stays comfortable
// (`min-h-9` ≈ 36 px, the WCAG/iOS floor) but the horizontal
// padding + type size drop a notch so five prompts pack into
// fewer rows and the left column stops towering over the
// Health-Score card.
className={cn(
"border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5",
"rounded-full border bg-transparent px-3.5 py-2 text-[13px]",
"text-muted-foreground inline-flex min-h-9 items-center gap-1.5",
"rounded-full border bg-transparent px-3 py-1.5 text-xs",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
)}