diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index ed0555b4d..4f7f81656 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -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 ( - - + {!hasCurve ? ( diff --git a/src/components/cycle/cycle-symptom-patterns.tsx b/src/components/cycle/cycle-symptom-patterns.tsx index 84a4e2a2a..a9d0ee94f 100644 --- a/src/components/cycle/cycle-symptom-patterns.tsx +++ b/src/components/cycle/cycle-symptom-patterns.tsx @@ -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 ( - - + {rows.length === 0 ? ( diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index cb24978a5..f7175c2c8 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -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({
- - + {rawChartMode ? ( diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx index e79a44e58..8229a2e47 100644 --- a/src/components/insights/correlation-card.tsx +++ b/src/components/insights/correlation-card.tsx @@ -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) { )} /> -
-
- {title} -

{subtitle}

-
- {result.status === "ok" && ( - - {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} - - )} +
+ + {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} + + ) : undefined + } + /> +

{subtitle}

diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 4958f36d1..6a0214972 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -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 ( - - +
- - + {t("insights.bloodGlucose.clinical.subtitle", { days: Math.max(1, Math.round(clinical.actualSpanDays)), diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 187ec34cf..4efb3f351 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -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} > -
- - {t(TITLE_KEY[kind])} - - {hasResult && data.result && ( -
- - {t(STRENGTH_KEY[data.result.strength])} - - {/* 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. */} - -
- )} -
+ + + {t(STRENGTH_KEY[data.result.strength])} + + {/* 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. */} + +
+ ) : undefined + } + />
{hasResult && data.result ? ( diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index 29106ce2f..fed7b4ec0 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -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) {
- - {t("insights.sleep.hypnogram.title")} - + {t("insights.sleep.hypnogram.subtitle", { asleep: formatMinutes(session.asleepMinutes, locale), diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index 8638beae8..282214646 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -80,8 +80,10 @@ describe("", () => { 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("", () => { 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(); const ready = render(); 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"); } }); diff --git a/src/components/insights/sleep/average-sleep-card.tsx b/src/components/insights/sleep/average-sleep-card.tsx index 8093f3c51..2071624e0 100644 --- a/src/components/insights/sleep/average-sleep-card.tsx +++ b/src/components/insights/sleep/average-sleep-card.tsx @@ -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 ( -
- - - {t("insights.sleep.average.title")} - -
+
-
- - - {t("insights.sleep.average.title")} - -
+
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 7f805a4c1..14c0e66be 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -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 ( -
- - - {t("insights.sleep.chronotype.title")} - -
+
-
- - - {t("insights.sleep.chronotype.title")} - -
+
{/* Uniform with the sleep-debt tile: a bold primary value (the band) at diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 66989458b..8799ad0e2 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -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 ( -
- - - {t("insights.sleep.debt.title")} - - {/* 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" ? ( - - ) : null} -
+ {/* 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. */} + + ) : undefined + } + />
-
- - - {t("insights.sleep.debt.title")} - - {/* 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" ? ( - - ) : null} -
+ {/* 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. */} + + ) : undefined + } + />
diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx index 5d0428e64..e31c032ed 100644 --- a/src/components/insights/tile-header.tsx +++ b/src/components/insights/tile-header.tsx @@ -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. */} -