refactor(ui): migrate hand-rolled tile headers onto TileHeader

Nine insights/cycle cards hand-rolled their icon+title rows at drifting
sizes and colours: the sleep trio (debt, chronotype, average) and the
glucose clinical panel led with h-4 accent icons, the cycle module
(predictions, BBT chart, symptom patterns) coloured its header icons
text-primary against both header contracts, and the hypnogram,
correlation card, and mood correlation cards carried bespoke title rows.
All of them now compose TileHeader.

TileHeader grows the two documented gaps instead of a fourth header
language: an optional icon (text-only tile titles) and the ONE sanctioned
compact variant size="sm" (icon h-4 w-4, title text-sm) used by the dense
correlation tiles. The sleep-rhythm guard test now pins the tile-header
slot instead of the retired accent icon colour.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 17:42:54 +02:00
1 parent 9a35cfb3e9
commit 8d36e69f75
12 files changed
+149 -129

No files matched your search

+3 -5
View File
@@ -35,7 +35,8 @@ import {
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { cn } from "@/lib/utils";
@@ -150,10 +151,7 @@ export function BbtChart({
return (
<Card data-slot="cycle-bbt-chart">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Thermometer className="text-primary h-4 w-4" aria-hidden="true" />
{t("cycle.bbt.title")}
</CardTitle>
<TileHeader icon={Thermometer} title={t("cycle.bbt.title")} />
</CardHeader>
<CardContent>
{!hasCurve ? (
@@ -3,7 +3,8 @@
import { useMemo } from "react";
import { Activity } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { PHASE_HUE } from "./phase-tokens";
@@ -56,10 +57,10 @@ export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) {
return (
<Card data-slot="cycle-symptom-patterns">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="text-primary h-4 w-4" aria-hidden="true" />
{t("cycle.insights.symptomPatternsTitle")}
</CardTitle>
<TileHeader
icon={Activity}
title={t("cycle.insights.symptomPatternsTitle")}
/>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
+2 -4
View File
@@ -10,6 +10,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
@@ -64,10 +65,7 @@ export function PredictionsPanel({
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="text-primary h-4 w-4" aria-hidden="true" />
{t("cycle.predictions.title")}
</CardTitle>
<TileHeader icon={Sparkles} title={t("cycle.predictions.title")} />
</CardHeader>
<CardContent className="space-y-4">
{rawChartMode ? (
+22 -18
View File
@@ -4,7 +4,8 @@ import dynamic from "next/dynamic";
import { Activity, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
@@ -116,23 +117,26 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
)}
/>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
{result.status === "ok" && (
<Badge
data-slot="correlation-card-confidence"
variant="outline"
className={cn(
"shrink-0 text-xs",
CONFIDENCE_BADGE_CLASS[result.confidenceBand.label],
)}
>
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
</Badge>
)}
<div className="space-y-0.5">
<TileHeader
size="sm"
title={title}
right={
result.status === "ok" ? (
<Badge
data-slot="correlation-card-confidence"
variant="outline"
className={cn(
"shrink-0 text-xs",
CONFIDENCE_BADGE_CLASS[result.confidenceBand.label],
)}
>
{t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])}
</Badge>
) : undefined
}
/>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
</CardHeader>
<CardContent className="space-y-3">
@@ -4,7 +4,8 @@ import Link from "next/link";
import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { InfoHint } from "@/components/ui/info-hint";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
@@ -97,10 +98,10 @@ export function GlucoseClinicalPanel() {
return (
<Card data-slot="glucose-clinical-panel" data-state="learning">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Droplet className="h-4 w-4" aria-hidden="true" />
{t("insights.bloodGlucose.clinical.learningTitle")}
</CardTitle>
<TileHeader
icon={Droplet}
title={t("insights.bloodGlucose.clinical.learningTitle")}
/>
</CardHeader>
<CardContent>
<LearningGate
@@ -134,10 +135,10 @@ export function GlucoseClinicalPanel() {
<Card data-slot="glucose-clinical-panel" data-state="asserted">
<CardHeader>
<div className="flex flex-col gap-0.5">
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Droplet className="h-4 w-4" aria-hidden="true" />
{t("insights.bloodGlucose.clinical.title")}
</CardTitle>
<TileHeader
icon={Droplet}
title={t("insights.bloodGlucose.clinical.title")}
/>
<span className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.subtitle", {
days: Math.max(1, Math.round(clinical.actualSpanDays)),
@@ -4,7 +4,8 @@ import dynamic from "next/dynamic";
import { Activity } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
@@ -99,28 +100,29 @@ function MoodCorrelationCard({
className={className}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold">
{t(TITLE_KEY[kind])}
</CardTitle>
{hasResult && data.result && (
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
{/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the
scatter as a half-empty row. Move it into an explainer icon
in the header so the card footprint stays tight. */}
<MoodExplainerIcon
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
/>
</div>
)}
</div>
<TileHeader
size="sm"
title={t(TITLE_KEY[kind])}
right={
hasResult && data.result ? (
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
{/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the
scatter as a half-empty row. Move it into an explainer icon
in the header so the card footprint stays tight. */}
<MoodExplainerIcon
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
/>
</div>
) : undefined
}
/>
</CardHeader>
<CardContent className="space-y-3">
{hasResult && data.result ? (
+3 -4
View File
@@ -10,7 +10,8 @@ import {
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { hourCycleOptions } from "@/lib/format-locale";
import { STAGE_COLORS } from "./sleep-stage-stacked-bar";
@@ -249,9 +250,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
<Card data-slot="sleep-hypnogram" className="gap-3 md:gap-3">
<CardHeader>
<div className="flex flex-col gap-0.5">
<CardTitle className="text-base font-semibold">
{t("insights.sleep.hypnogram.title")}
</CardTitle>
<TileHeader title={t("insights.sleep.hypnogram.title")} />
<span className="text-muted-foreground text-xs">
{t("insights.sleep.hypnogram.subtitle", {
asleep: formatMinutes(session.asleepMinutes, locale),
@@ -80,8 +80,10 @@ describe("<SleepDebtCard>", () => {
for (const html of [partial, ready]) {
expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
expect(html).toContain("md:py-6");
// Semantic info token, not the raw Dracula cyan.
expect(html).toContain("text-info");
// Header rides the canonical TileHeader (foreground icon), never a
// hand-rolled accent-coloured row or the raw Dracula cyan.
expect(html).toContain('data-slot="tile-header"');
expect(html).not.toContain("text-info");
expect(html).not.toContain("text-dracula-cyan");
}
});
@@ -123,11 +125,12 @@ describe("<ChronotypeCard>", () => {
expect(html).toContain("text-2xl");
});
it("uses the semantic info token, never the raw Dracula cyan", () => {
it("renders the canonical TileHeader, never the raw Dracula cyan", () => {
const learning = render(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
const ready = render(<ChronotypeCard chronotype={READY_CHRONO} />);
for (const html of [learning, ready]) {
expect(html).toContain("text-info");
expect(html).toContain('data-slot="tile-header"');
expect(html).not.toContain("text-info");
expect(html).not.toContain("text-dracula-cyan");
}
});
@@ -3,8 +3,9 @@
import { BedDouble } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { TileHeader } from "@/components/insights/tile-header";
import type { AverageSleepDto } from "./use-sleep-rhythm";
/** Whole hours + minutes from a minute total, for the headline figure. */
@@ -35,12 +36,10 @@ export function AverageSleepCard({ average }: { average: AverageSleepDto }) {
return (
<Card data-slot="average-sleep-card">
<CardHeader>
<div className="flex items-center gap-2">
<BedDouble className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.average.title")}
</CardTitle>
</div>
<TileHeader
icon={BedDouble}
title={t("insights.sleep.average.title")}
/>
</CardHeader>
<CardContent>
<LearningGate
@@ -59,12 +58,10 @@ export function AverageSleepCard({ average }: { average: AverageSleepDto }) {
return (
<Card data-slot="average-sleep-card">
<CardHeader>
<div className="flex items-center gap-2">
<BedDouble className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.average.title")}
</CardTitle>
</div>
<TileHeader
icon={BedDouble}
title={t("insights.sleep.average.title")}
/>
</CardHeader>
<CardContent>
<div className="space-y-0.5">
@@ -4,8 +4,9 @@ import { useState } from "react";
import { Clock, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { TileHeader } from "@/components/insights/tile-header";
import type { ChronotypeDto } from "./use-sleep-rhythm";
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
@@ -48,12 +49,10 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
return (
<Card data-slot="chronotype-card">
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
</div>
<TileHeader
icon={Clock}
title={t("insights.sleep.chronotype.title")}
/>
</CardHeader>
<CardContent>
<LearningGate
@@ -74,12 +73,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
return (
<Card data-slot="chronotype-card">
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
</div>
<TileHeader icon={Clock} title={t("insights.sleep.chronotype.title")} />
</CardHeader>
<CardContent className="space-y-4">
{/* Uniform with the sleep-debt tile: a bold primary value (the band) at
@@ -3,8 +3,9 @@
import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { InfoHint } from "@/components/ui/info-hint";
import { TileHeader } from "@/components/insights/tile-header";
import { LearningGate } from "@/components/ui/learning-gate";
import type { SleepDebtDto } from "./use-sleep-rhythm";
@@ -33,19 +34,19 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Moon className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
{/* v1.25.0 — when the active source on the user's sleep-debt ladder
is our own COMPUTED engine (every user today, until a provider
ships a native debt), explain what the figure means and that it
differs from a wearable's native number. */}
{debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
) : null}
</div>
{/* v1.25.0 — when the active source on the user's sleep-debt ladder
is our own COMPUTED engine (every user today, until a provider
ships a native debt), explain what the figure means and that it
differs from a wearable's native number. */}
<TileHeader
icon={Moon}
title={t("insights.sleep.debt.title")}
right={
debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
) : undefined
}
/>
</CardHeader>
<CardContent>
<LearningGate
@@ -65,19 +66,19 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Moon className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
{/* v1.25.0 — the resolved full-debt card is the common case and the
place the figure most needs context: explain the COMPUTED engine
and that it differs from a wearable's native number here too, not
only in the still-learning `partial` state above. */}
{debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
) : null}
</div>
{/* v1.25.0 — the resolved full-debt card is the common case and the
place the figure most needs context: explain the COMPUTED engine
and that it differs from a wearable's native number here too, not
only in the still-learning `partial` state above. */}
<TileHeader
icon={Moon}
title={t("insights.sleep.debt.title")}
right={
debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
) : undefined
}
/>
</CardHeader>
<CardContent>
<div className="space-y-0.5">
+26 -4
View File
@@ -30,13 +30,23 @@ interface TileHeaderProps {
* Leading glyph. Accepts a Lucide icon component (or any component that
* takes a `className`) so the caller passes the component itself
* (`icon={Target}`), not a pre-sized node — `TileHeader` owns the size
* and colour so every tile header matches.
* and colour so every tile header matches. Optional: a text-only tile
* header (chart cards whose title needs no glyph) omits it and still
* gets the canonical row + `CardTitle` treatment.
*/
icon: ComponentType<{ className?: string }>;
icon?: ComponentType<{ className?: string }>;
/** Heading text (or node). Rendered inside a `CardTitle` at `text-base`. */
title: ReactNode;
/** Optional trailing affordance pinned to the right edge of the row. */
right?: ReactNode;
/**
* The ONE sanctioned compact variant: icon `h-4 w-4`, title `text-sm` —
* for dense correlation/stat tiles that sit several to a row where the
* full `text-base` header visually overpowers the tile body. Everything
* else stays on the default; do not express a third size via
* `titleClassName`.
*/
size?: "default" | "sm";
className?: string;
titleClassName?: string;
/** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */
@@ -47,6 +57,7 @@ export function TileHeader({
icon: Icon,
title,
right,
size = "default",
className,
titleClassName,
id,
@@ -58,8 +69,19 @@ export function TileHeader({
>
{/* Icon and heading share the foreground colour — the calm,
high-contrast read the "Einschätzung" card established. */}
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<CardTitle id={id} className={cn("text-base", titleClassName)}>
{Icon ? (
<Icon
className={cn(
"text-foreground shrink-0",
size === "sm" ? "h-4 w-4" : "h-5 w-5",
)}
aria-hidden="true"
/>
) : null}
<CardTitle
id={id}
className={cn(size === "sm" ? "text-sm" : "text-base", titleClassName)}
>
{title}
</CardTitle>
{right ? <div className="ml-auto flex items-center">{right}</div> : null}