fix(insights): improve narrative contrast and spacing

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 21:28:29 +02:00
1 parent ab450bbfd1
commit 90b1a4eaee
11 files changed
+92 -44

No files matched your search

+36 -30
View File
@@ -26,6 +26,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { HealthScoreCard } from "@/components/insights/health-score-card";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
@@ -474,36 +475,41 @@ export default function InsightsPageClient() {
/>
),
"daily-briefing": flags.briefing ? (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
// v1.15.20 — the read path reports a 422 (no provider configured)
// as its own outcome; swap the futile regenerate CTA for a quiet
// Settings → AI hint instead of an eternal "preparing" loop.
noProvider={advisor.readOutcome === "no-provider"}
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
// is connected, so it can never refresh. Pair the relative-age
// footer with a discreet connect-provider hint. Only fires when a
// briefing is actually rendered (the empty-state path owns the
// `noProvider` branch above).
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// v1.25 — the last generation attempt failed. On a held briefing this
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
// card it swaps the generic empty state for an honest "couldn't
// generate" one. Suppressed when no provider is configured (that hint
// owns the surface and a retry would be futile).
generationFailed={advisor.generationFailed && advisor.hasProvider}
// v1.25.3 — failure class points the empty-state hint at the right
// lever (raise the response timeout vs re-check the provider).
generationFailureClass={advisor.generationFailureClass}
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
// briefing; the card explains the omission instead of "no briefing
// yet" (which made the regenerate button read as doing nothing).
omittedReason={advisor.briefingOmittedReason}
/>
<>
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
// v1.15.20 — the read path reports a 422 (no provider configured)
// as its own outcome; swap the futile regenerate CTA for a quiet
// Settings → AI hint instead of an eternal "preparing" loop.
noProvider={advisor.readOutcome === "no-provider"}
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
// is connected, so it can never refresh. Pair the relative-age
// footer with a discreet connect-provider hint. Only fires when a
// briefing is actually rendered (the empty-state path owns the
// `noProvider` branch above).
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// v1.25 — the last generation attempt failed. On a held briefing this
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
// card it swaps the generic empty state for an honest "couldn't
// generate" one. Suppressed when no provider is configured (that hint
// owns the surface and a retry would be futile).
generationFailed={advisor.generationFailed && advisor.hasProvider}
// v1.25.3 — failure class points the empty-state hint at the right
// lever (raise the response timeout vs re-check the provider).
generationFailureClass={advisor.generationFailureClass}
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
// briefing; the card explains the omission instead of "no briefing
// yet" (which made the regenerate button read as doing nothing).
omittedReason={advisor.briefingOmittedReason}
/>
{analytics?.healthScore ? (
<HealthScoreCard report={analytics.healthScore} className="mt-6" />
) : null}
</>
) : null,
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
trends: (
@@ -185,7 +185,7 @@ describe("<TodayHero>", () => {
sourceMetric: "pulse",
tone: "info",
headline: "Pulse is settling lately",
nudge: null,
nudge: "",
delta: null,
},
})}
@@ -301,7 +301,7 @@ describe("<HeroStrip>", () => {
healthScore={SCORE_REPORT}
/>,
);
expect(html).toMatch(/data-slot="health-score-card"/);
expect(html).toMatch(/data-slot="health-score-card-compact"/);
expect(html).toMatch(/data-band="green"/);
expect(html).toContain(">86<");
});
@@ -406,7 +406,7 @@ describe("<HeroStrip>", () => {
healthScore={SCORE_REPORT}
/>,
);
expect(html).toContain('data-slot="health-score-card"');
expect(html).toContain('data-slot="health-score-card-compact"');
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
});
});
@@ -183,11 +183,15 @@ describe("<InsightStatusCard>", () => {
expect(empty).toContain("No assessment yet.");
expect(populated).toContain('data-slot="insight-assessment"');
for (const html of [loading, preparing, noProvider, empty, populated]) {
for (const html of [loading, preparing, noProvider, empty]) {
expect(html).toContain("gap-2");
expect(html).toContain("py-3");
expect(html).toContain("md:py-4");
}
expect(populated).toContain("gap-1.5");
expect(populated).toContain("space-y-1.5");
expect(populated).toContain("py-3");
expect(populated).toContain("md:py-4");
expect(populated).toContain("text-foreground");
});
});
@@ -14,6 +14,7 @@ import type {
} from "@/lib/analytics/score/types";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { ScoreRing } from "@/components/insights/derived/score-ring";
const PILLAR_LABEL_KEY: Record<ScorePillarId, string> = {
BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure",
@@ -83,6 +84,8 @@ export interface HealthScoreCardProps {
negative: string[];
} | null;
returnToBand?: { metricLabel: string; daysInside: number } | null;
/** Keep only the score face when the card is used above the fold. */
compact?: boolean;
className?: string;
}
@@ -90,6 +93,7 @@ export function HealthScoreCard({
report,
tension,
returnToBand,
compact = false,
className,
}: HealthScoreCardProps) {
const { t } = useTranslations();
@@ -223,6 +227,36 @@ export function HealthScoreCard({
? 1 / eligibleCount
: 0,
}));
if (compact) {
return (
<div
data-slot="health-score-card-compact"
className={cn(
"border-border/70 bg-card/80 flex min-w-0 flex-col items-center justify-center gap-2 rounded-xl border p-4",
className,
)}
>
<ScoreRing
score={composite.status === "ok" ? composite.value.score : null}
band={composite.status === "ok" ? composite.value.band : undefined}
size="md"
flat
label={t("insights.healthScore.label")}
/>
{composite.status === "ok" ? (
<p className="text-muted-foreground text-xs">
{t(`insights.healthScore.band.${composite.value.band}`)}
</p>
) : (
<p className="text-muted-foreground text-xs">
{t("insights.healthScore.insufficient", {
count: composite.coverage.presentInputs,
})}
</p>
)}
</div>
);
}
return (
<div
data-slot="health-score-card"
+1
View File
@@ -383,6 +383,7 @@ export function HeroStrip({
report={healthScore}
tension={tensionProp}
returnToBand={returnToBandProp}
compact
className="w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]"
/>
)}
@@ -264,7 +264,7 @@ export function InsightStatusCard({
// v1.12.2 — stable hook for the spine guard test, which asserts the
// assessment is the LAST content block on every bespoke metric page.
data-slot="insight-assessment"
// Compact card density (`gap-2 py-3 md:py-4`) — the sanctioned tight
// Compact card density (`gap-1.5 py-3 md:py-4`) — the sanctioned tight
// tier, so the assessment shares one header→body rhythm with the
// target summary, the read strip, and the stat strip on the same
// spine. Earlier bespoke values (`gap-1.5` + `md:py-5`) predate the
@@ -272,7 +272,7 @@ export function InsightStatusCard({
// lands on every status variant so the gap is consistent across
// states. (The one-time purple left accent stays dropped — the plain
// card border carries enough separation.)
className="animate-insight-in gap-2 py-3 md:py-4"
className="animate-insight-in gap-1.5 py-3 md:py-4"
>
<CardHeader>
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
@@ -298,7 +298,7 @@ export function InsightStatusCard({
}
/>
</CardHeader>
<CardContent className="space-y-2">
<CardContent className="space-y-1.5">
{/* v1.4.27 — defence-in-depth strip. Cached status text from
pre-v1.4.27 rows can still carry literal `metric:<TYPE>`
tokens the model embedded on the assumption a chart would
@@ -197,7 +197,7 @@ function SeriesBlock({
data-slot="metric-stat-series"
data-series={dataKey}
data-windowed={windowed ? "true" : undefined}
className="space-y-2"
className="space-y-1.5"
>
{seriesLabel ? (
<TileHeader
@@ -207,7 +207,7 @@ function SeriesBlock({
/>
) : null}
{description ? (
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="text-foreground text-sm leading-relaxed">
{description}
</p>
) : null}
@@ -260,7 +260,7 @@ export function MetricStatStrip({
data-multi-series="true"
role="group"
aria-label={groupLabel ?? t("insights.subPage.stats.label")}
className="gap-2 py-3 md:py-4"
className="gap-1.5 py-3 md:py-4"
>
<CardContent>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
@@ -293,7 +293,7 @@ export function MetricStatStrip({
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="gap-2 py-3 md:py-4"
className="gap-1.5 py-3 md:py-4"
>
<CardContent>
<SeriesBlock
@@ -326,7 +326,7 @@ function TargetReferencePanel({
<Card
data-slot="metric-target-summary"
data-target-type={target.type}
className="gap-2 py-3 md:py-4"
className="gap-1.5 py-3 md:py-4"
>
{/* Row 1: the canonical tile header — a single-word "Ziel" / "Target"
heading (the range numbers live on the range bar below, so the
+4 -1
View File
@@ -38,7 +38,10 @@ import { InlineLearnLink } from "@/components/ui/learn-more-link";
/** Default paragraph styling shared by every prose surface + the Coach
* streaming tail, so the streamed and settled renders line up exactly. */
export const PROSE_PARAGRAPH_CLASS =
"leading-relaxed break-words [&:not(:first-child)]:mt-3";
// Keep consecutive narrative blocks visually connected. The previous
// 12px paragraph step made short assessments look like separate cards on
// mobile, especially directly below a status pill or tile heading.
"leading-relaxed break-words [&:not(:first-child)]:mt-2";
/**
* Split a trusted-as-text string into real paragraphs on blank lines.
+1 -1
View File
@@ -389,7 +389,7 @@ export function SubPageShell({
</p>
) : null}
{description && !explainerMetric ? (
<p className="text-muted-foreground text-sm">{description}</p>
<p className="text-foreground text-sm">{description}</p>
) : null}
</header>
{/* v1.12.6 — the Min / Max / Median / Mittelwert stat strip LEADS