fix(ui): close consistency-audit contrast and duplication findings

Today hero's top-signal delta used opacity-on-muted text, landing below
AA on the app's most prominent card; five mood charts and the sleep-
stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no
(or an unused) light-theme counterpart. Route all of them through
existing semantic/chart tokens instead (destructive/warning/success,
chart-1..5, and a newly minted --chart-inbed with its own Alucard
override) so every hue is AA-tuned per theme.

Also collapses three duplicated primitives the audit flagged: the
click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one
touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle
were verbatim copy-paste and now wrap one shared ui/view-toggle; the
triplicated sport-icon map (already drifted — mixedCardio rendered a
different glyph in the list vs the detail/dashboard views) moves to
lib/workouts/sport-icons.ts with HeartPulse as the settled icon.

Adds a no-restricted-imports lint rule banning direct recharts imports
outside the chart-runtime barrel, catching the bundle-duplication
mistake at local lint instead of PR-CI.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 01:23:37 +02:00
1 parent 1ff179178f
commit 247bc4f33c
25 files changed
+401 -400

No files matched your search

@@ -16,7 +16,7 @@ import { SectionHeading } from "@/components/insights/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { CoverageMeter } from "./coverage-meter";
import { VitalsInfoTip } from "./vitals-info-tip";
import { InfoPopover } from "@/components/ui/info-popover";
import { METRIC_PROVENANCE } from "./standards";
import { type DerivedBatchRead } from "./use-derived-metric";
import {
@@ -171,11 +171,11 @@ function tileOrder(layout: InsightsLayout | undefined, tileId: string): number {
/**
* The (i) info-tip trigger for one derived metric, wired from the map.
* `provenance` (inputs / source / window / asOf) isn't rendered here —
* `VitalsInfoTip` carries only the method/caveat + cited standard, mirroring
* what `ProvenanceExplainer` itself renders today — but the param is kept so
* every call site (which already reads `data.provenance` off the derived
* batch) doesn't need touching if a future pass restores the detail.
* `provenance` (inputs / source / window / asOf) isn't rendered here — the
* shared `InfoPopover` carries only the method/caveat + cited standard,
* mirroring what `ProvenanceExplainer` itself renders today — but the param
* is kept so every call site (which already reads `data.provenance` off the
* derived batch) doesn't need touching if a future pass restores the detail.
*/
function MetricProvenance({
metric,
@@ -195,7 +195,13 @@ function MetricProvenance({
{t(meta.methodKey)}
</>
);
return <VitalsInfoTip method={method} standard={meta.standard} />;
return (
<InfoPopover
content={method}
link={meta.standard}
label={t("insights.derived.vitals.infoLabel")}
/>
);
}
interface TileProps {
@@ -291,7 +297,7 @@ function BaselineTile({
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
// "typical range = median ± MAD" caption repeated across every vital
// baseline tile and read as clutter. v1.29.2 restores the context —
// now behind the compact (i) trigger (`VitalsInfoTip`) instead of a
// now behind the compact (i) trigger (`InfoPopover`) instead of a
// full-width caption, so every baseline tile carries it again
// without the repetition or the header-row squeeze that clipped the
// cardio-fitness heading.
@@ -1,90 +0,0 @@
"use client";
import { useState } from "react";
import { Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ProvenanceStandard } from "./provenance-explainer";
/**
* v1.29.2 QoL — the vitals-dashboard tile's (i) info affordance.
*
* v1.29.1 dropped the always-on "your personal normal range is the
* median…" method caption from every `VITALS_BASELINE` tile because it
* repeated verbatim across up to six tiles and read as clutter at the top
* of the vitals surface — and, on the narrower tiles, squeezed the heading
* itself (the VO₂max/cardio-fitness clip). Rather than reintroduce the
* caption at full length, the SAME method/caveat/standard content moves
* behind a small (i) trigger: a click-opened `Popover` (not a hover
* tooltip — the vitals grid sees most of its use on touch), so the context
* is one tap away instead of permanently occupying header space.
*
* This is a DELIBERATE, narrowly-scoped exception to the v1.22 "no
* icon-only disclosure" direction (`ProvenanceExplainer`'s own docstring):
* that decision targeted the always-visible-caption pattern on OTHER
* surfaces (coincident-deviation, the score-anatomy pages), which stay
* unchanged. The vitals grid is dense enough, and the caption repetitive
* enough, that the same content is better one tap away here.
*/
export interface VitalsInfoTipProps {
/** Plain-language method (+ optional caveat) description — text children only. */
method: React.ReactNode;
/** Optional cited standard, rendered as a plain external link. */
standard?: ProvenanceStandard;
className?: string;
}
export function VitalsInfoTip({
method,
standard,
className,
}: VitalsInfoTipProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="vitals-info-trigger"
aria-label={t("insights.derived.vitals.infoLabel")}
aria-expanded={open}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
data-slot="vitals-info-body"
align="end"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p className="text-muted-foreground text-xs leading-snug">{method}</p>
{standard ? (
<a
href={standard.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary block text-xs leading-snug hover:underline"
>
{standard.name}
</a>
) : null}
</PopoverContent>
</Popover>
);
}
@@ -3,7 +3,7 @@
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import { InfoHint } from "@/components/ui/info-hint";
import { InfoPopover } from "@/components/ui/info-popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
@@ -149,7 +149,7 @@ function AdvancedStat({
<div className="space-y-0.5">
<dt className="text-muted-foreground flex items-center gap-1 text-xs">
{label}
<InfoHint label={help} />
<InfoPopover content={help} />
</dt>
<dd className="text-foreground text-base font-semibold tabular-nums">
{value}
@@ -6,7 +6,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { InfoHint } from "@/components/ui/info-hint";
import { InfoPopover } from "@/components/ui/info-popover";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
@@ -198,7 +198,9 @@ export function GlucoseClinicalPanel() {
<div className="space-y-0.5" data-slot="glucose-cv">
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{t("insights.bloodGlucose.clinical.cv.label")}
<InfoHint label={t("insights.bloodGlucose.clinical.cv.help")} />
<InfoPopover
content={t("insights.bloodGlucose.clinical.cv.help")}
/>
</span>
<div className="flex items-center gap-1.5">
<span className="text-foreground text-lg font-semibold tabular-nums">
@@ -285,7 +287,7 @@ function Metric({
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{icon}
{label}
{help ? <InfoHint label={help} /> : null}
{help ? <InfoPopover content={help} /> : null}
</span>
<span className="text-foreground flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">{value}</span>
@@ -34,9 +34,10 @@ describe("mood tag bars — full-saturation palette (v1.19.0)", () => {
for (const fill of fills ?? []) {
expect(fill).not.toContain("opacity-55");
}
// The level hue itself stays a Dracula token.
expect(html).toContain("var(--dracula-red)");
expect(html).toContain("var(--dracula-green)");
// The level hue itself stays a token — semantic since v1.28.17 (was a
// raw `--dracula-*` primitive; see mood-tag-breakdown.tsx).
expect(html).toContain("var(--destructive)");
expect(html).toContain("var(--success)");
});
it("structured tag breakdown bars render at full opacity", () => {
@@ -66,12 +66,16 @@ const Y_LABEL_KEY: Record<MetricKind, string> = {
bloodPressure: "insights.mood.correlation.bloodPressureAxis",
};
// v1.28.17 — the shared `--chart-1..5` vocabulary (same values as the
// `--dracula-purple/cyan/pink/green/orange` primitives it replaces, per
// theme), not raw palette tokens — matches the `no-raw-palette-color`
// discipline and the app's other chart-series colouring.
const FILL_BY_KIND: Record<MetricKind, string> = {
sleep: "var(--dracula-purple)",
steps: "var(--dracula-cyan)",
pulse: "var(--dracula-pink)",
weight: "var(--dracula-green)",
bloodPressure: "var(--dracula-orange)",
sleep: "var(--chart-1)",
steps: "var(--chart-4)",
pulse: "var(--chart-3)",
weight: "var(--chart-2)",
bloodPressure: "var(--chart-5)",
};
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
@@ -28,12 +28,17 @@ export interface MoodDistributionRow {
count: number;
}
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives: every one of
// these already carries an AA-tuned Alucard (light-theme) override, but
// routing through the meaning-bearing alias (destructive/warning/yellow/
// success) keeps this map legible as a severity ladder rather than an
// arbitrary palette pick, matching the `no-raw-palette-color` discipline.
const BAR_COLOR_BY_SCORE: Record<number, string> = {
1: "var(--dracula-red)",
2: "var(--dracula-orange)",
1: "var(--destructive)",
2: "var(--warning)",
3: "var(--dracula-yellow)",
4: "var(--dracula-green)",
5: "var(--dracula-green)",
4: "var(--success)",
5: "var(--success)",
};
export function MoodDistributionChart({
@@ -68,8 +73,8 @@ export function MoodDistributionChart({
// not overridden in `:root.light` (Alucard white card) → white-on-
// white. `--muted-foreground` is the token every other in-app chart
// uses for axis ticks and is legible in both themes; dark mode is
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
// axis text legibility changes here.
// unchanged. The mood-hue bar Cells route through semantic tokens
// (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
interval={0}
@@ -80,8 +85,8 @@ export function MoodDistributionChart({
// not overridden in `:root.light` (Alucard white card) → white-on-
// white. `--muted-foreground` is the token every other in-app chart
// uses for axis ticks and is legible in both themes; dark mode is
// unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the
// axis text legibility changes here.
// unchanged. The mood-hue bar Cells route through semantic tokens
// (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
width={28}
@@ -117,7 +122,7 @@ export function MoodDistributionChart({
{data.map((row) => (
<Cell
key={row.score}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--dracula-purple)"}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--primary)"}
/>
))}
</Bar>
@@ -19,11 +19,13 @@ export interface MoodTagRow {
avgScore: number;
}
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see
// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap).
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 2) return "var(--destructive)";
if (score < 3) return "var(--warning)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
return "var(--success)";
}
export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
@@ -47,11 +47,13 @@ const LABEL_KEYS: Record<MoodTimeOfDayBucket, string> = {
night: "insights.mood.timeOfDay.night",
};
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see
// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap).
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 2) return "var(--destructive)";
if (score < 3) return "var(--warning)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
return "var(--success)";
}
export function MoodTimeOfDayChart({
@@ -39,11 +39,13 @@ const WEEKDAY_LABEL_KEYS = [
"charts.weekdaysFull.sun",
] as const;
// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see
// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap).
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 2) return "var(--destructive)";
if (score < 3) return "var(--warning)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
return "var(--success)";
}
export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
@@ -88,7 +90,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
// v1.15.14 — theme-aware axis text (see mood-distribution-chart).
// `--dracula-fg` was white-on-white on the light-mode card; switch
// to the shared `--muted-foreground` axis-tick token. Bar mood
// hues stay `--dracula-*`; dark mode is unchanged.
// hues route through semantic tokens (see `colorForScore`);
// dark mode is unchanged.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
interval={0}
@@ -99,7 +102,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
// v1.15.14 — theme-aware axis text (see mood-distribution-chart).
// `--dracula-fg` was white-on-white on the light-mode card; switch
// to the shared `--muted-foreground` axis-tick token. Bar mood
// hues stay `--dracula-*`; dark mode is unchanged.
// hues route through semantic tokens (see `colorForScore`);
// dark mode is unchanged.
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--muted-foreground)"
width={24}
@@ -94,7 +94,7 @@ export const STAGE_COLORS: Record<string, string> = {
CORE: "var(--info)", // dracula-cyan — bulk of sleep
ASLEEP: "var(--success)", // dracula-green — legacy iOS 15- unspecified
AWAKE: "var(--dracula-yellow)", // dracula-yellow — wake bouts
IN_BED: "#6272a4", // dracula-comment — pre-asleep
IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep
};
function formatMinutes(total: number, locale: string): string {
@@ -4,7 +4,7 @@ import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { InfoHint } from "@/components/ui/info-hint";
import { InfoPopover } from "@/components/ui/info-popover";
import { TileHeader } from "@/components/insights/tile-header";
import { LearningGate } from "@/components/ui/learning-gate";
import type { SleepDebtDto } from "./use-sleep-rhythm";
@@ -43,7 +43,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
title={t("insights.sleep.debt.title")}
right={
debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
<InfoPopover content={t("insights.sleep.debt.computedInfo")} />
) : undefined
}
/>
@@ -75,7 +75,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
title={t("insights.sleep.debt.title")}
right={
debt.source === "COMPUTED" ? (
<InfoHint label={t("insights.sleep.debt.computedInfo")} />
<InfoPopover content={t("insights.sleep.debt.computedInfo")} />
) : undefined
}
/>
+1 -32
View File
@@ -2,17 +2,12 @@
import type { ReactNode } from "react";
import {
Activity,
Bike,
Dumbbell,
Flame,
Footprints,
HeartPulse,
Map,
Mountain,
PersonStanding,
Timer,
type LucideIcon,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -23,6 +18,7 @@ import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
/**
@@ -35,33 +31,6 @@ import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
* `WorkoutDetailPayload`.
*/
const SPORT_TYPE_ICON: Record<string, LucideIcon> = {
walking: Footprints,
running: PersonStanding,
cycling: Bike,
hiking: Mountain,
swimming: Activity,
rowing: Activity,
elliptical: Activity,
stairClimber: Activity,
yoga: PersonStanding,
mindAndBody: PersonStanding,
strength: Dumbbell,
hiit: Activity,
dance: Activity,
golf: Activity,
tennis: Activity,
basketball: Activity,
soccer: Activity,
crossTraining: Activity,
mixedCardio: HeartPulse,
other: Activity,
};
function iconForSport(sportType: string): LucideIcon {
return SPORT_TYPE_ICON[sportType] ?? Activity;
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
+1 -37
View File
@@ -1,16 +1,6 @@
"use client";
import Link from "next/link";
import {
Activity,
Bike,
Dumbbell,
Footprints,
Heart,
Mountain,
PersonStanding,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
@@ -19,6 +9,7 @@ import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
/**
@@ -33,33 +24,6 @@ import type { WorkoutListEntry } from "@/hooks/use-workouts";
* lands here.
*/
const SPORT_TYPE_ICON: Record<string, LucideIcon> = {
walking: Footprints,
running: PersonStanding,
cycling: Bike,
hiking: Mountain,
swimming: Activity,
rowing: Activity,
elliptical: Activity,
stairClimber: Activity,
yoga: PersonStanding,
mindAndBody: PersonStanding,
strength: Dumbbell,
hiit: Activity,
dance: Activity,
golf: Activity,
tennis: Activity,
basketball: Activity,
soccer: Activity,
crossTraining: Activity,
mixedCardio: Heart,
other: Activity,
};
function iconForSport(sportType: string): LucideIcon {
return SPORT_TYPE_ICON[sportType] ?? Activity;
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);