From 247bc4f33c17ca30476e200dcdadcd445cbea1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:02:24 +0200 Subject: [PATCH] fix(ui): close consistency-audit contrast and duplication findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- eslint.config.mjs | 48 ++++++++++ src/app/globals.css | 14 +++ src/components/daily/today-hero.tsx | 2 +- .../dashboard/recent-workouts-tile.tsx | 39 +------- .../insights/derived/vitals-dashboard.tsx | 22 +++-- .../insights/derived/vitals-info-tip.tsx | 90 ----------------- .../glucose/glucose-advanced-disclosure.tsx | 4 +- .../glucose/glucose-clinical-panel.tsx | 8 +- .../__tests__/mood-tag-bars-muted.test.tsx | 7 +- .../insights/mood/mood-correlation-cards.tsx | 14 ++- .../insights/mood/mood-distribution-chart.tsx | 23 +++-- .../insights/mood/mood-tag-breakdown.tsx | 8 +- .../insights/mood/mood-time-of-day-chart.tsx | 8 +- .../insights/mood/mood-weekday-chart.tsx | 14 ++- .../insights/sleep-stage-stacked-bar.tsx | 2 +- .../insights/sleep/sleep-debt-card.tsx | 6 +- src/components/insights/workout-detail.tsx | 33 +------ src/components/insights/workout-list.tsx | 38 +------- .../medications/medication-view-toggle.tsx | 70 ++++---------- .../mental-health/mental-wellbeing.tsx | 2 +- .../module-list/module-view-toggle.tsx | 71 +++++--------- src/components/ui/info-hint.tsx | 60 ------------ src/components/ui/info-popover.tsx | 96 +++++++++++++++++++ src/components/ui/view-toggle.tsx | 77 +++++++++++++++ src/lib/workouts/sport-icons.ts | 45 +++++++++ 25 files changed, 401 insertions(+), 400 deletions(-) delete mode 100644 src/components/insights/derived/vitals-info-tip.tsx delete mode 100644 src/components/ui/info-hint.tsx create mode 100644 src/components/ui/info-popover.tsx create mode 100644 src/components/ui/view-toggle.tsx create mode 100644 src/lib/workouts/sport-icons.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 0f6b64259..9d66c3ee6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -77,6 +77,54 @@ const eslintConfig = defineConfig([ "healthlog/spacing-scale": "error", }, }, + // v1.28.17 — every recharts-rendering component funnels through the + // shared `chart-runtime.ts` barrel (see its file header): pointing N + // `next/dynamic` boundaries at N different modules mints a separate + // ~312 KB recharts chunk per boundary, a regression the project already + // paid for once. Direct `from "recharts"` imports outside the barrel's + // own static import graph previously only tripped the bundle-budget + // gate at PR-CI; this rule catches the same mistake at local lint, + // mirroring how `healthlog/safe-fetch-required` scopes its own + // wrapper-internals exemption by file list. + { + files: ["src/**/*.{ts,tsx}"], + ignores: [ + "src/components/charts/chart-runtime.ts", + "src/components/admin/host-metrics-chart.tsx", + "src/components/charts/health-chart.tsx", + "src/components/charts/medication-compliance-chart.tsx", + "src/components/charts/mood-chart.tsx", + "src/components/charts/nutrient-daily-bar-chart.tsx", + "src/components/charts/scatter-correlation-chart.tsx", + "src/components/custom-metrics/custom-metric-chart.tsx", + "src/components/cycle/bbt-chart.tsx", + "src/components/insights/derived/delta-sparkline.tsx", + "src/components/insights/intraday-pulse-chart.tsx", + "src/components/insights/mood/mood-distribution-chart.tsx", + "src/components/insights/mood/mood-time-of-day-chart.tsx", + "src/components/insights/mood/mood-weekday-chart.tsx", + "src/components/insights/sleep-stage-stacked-bar.tsx", + "src/components/labs/lab-biomarker-chart.tsx", + "src/components/medications/detail/efficacy/efficacy-chart.tsx", + "src/components/medications/dose-strength-curve.tsx", + "src/components/medications/drug-level-chart.tsx", + "src/components/mental-health/assessment-history-chart.tsx", + ], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "recharts", + message: + "Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.", + }, + ], + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/src/app/globals.css b/src/app/globals.css index 067640131..441b14be6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -160,6 +160,17 @@ --dracula-red: #ff5555; --dracula-yellow: #f1fa8c; --dracula-lavender: #d6acff; + /* + * v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment` + * above (an orphaned token — no `@theme` utility was ever generated for + * it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never + * matched the classic Dracula "Comment" blue-grey this chart actually + * paints). Minted here instead of retuning the unrelated token, so this + * default carries the exact hex the stacked bar hardcoded before + * (`#6272a4`) and the light override below (`:root.light`) is this + * token's own AA-tuned counterpart. + */ + --chart-inbed: #6272a4; /* * Semantic feedback tokens (see @theme `--color-success` / `--color-warning` @@ -378,6 +389,9 @@ /* Lavender has no semantic twin; a violet one step lighter than purple, * AA-tuned — 4.97:1 card / 4.63:1 bg. */ --dracula-lavender: #7c50e0; + /* IN_BED sleep-stage fill (see the `:root` default above) — same + * blue-grey hue family, darkened for AA — 6.66:1 card / 6.03:1 bg. */ + --chart-inbed: #4a5b8a; --sidebar: oklch(0.988 0.003 300); --sidebar-foreground: oklch(0.3 0.012 295); --sidebar-primary: #644ac9; diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 18b8f8a65..c9bebb8cb 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -138,7 +138,7 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { > {topSignal.headline} {topSignal.delta ? ( - + {" "} · {topSignal.delta} diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index 6caaa66f0..7fcf86efc 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -1,16 +1,7 @@ "use client"; import Link from "next/link"; -import { - Activity, - Bike, - Dumbbell, - Footprints, - HeartPulse, - Mountain, - PersonStanding, - type LucideIcon, -} from "lucide-react"; +import { Activity } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; @@ -19,6 +10,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { iconForSport } from "@/lib/workouts/sport-icons"; /** * v1.4.32 — ``. @@ -38,33 +30,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; * round-trip per session. */ -const SPORT_TYPE_ICON: Record = { - 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); diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 57aa57ddf..ec0e39478 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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 ; + return ( + + ); } 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. diff --git a/src/components/insights/derived/vitals-info-tip.tsx b/src/components/insights/derived/vitals-info-tip.tsx deleted file mode 100644 index 753ab3fbc..000000000 --- a/src/components/insights/derived/vitals-info-tip.tsx +++ /dev/null @@ -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 ( - - - - - -

{method}

- {standard ? ( - - {standard.name} - - ) : null} -
-
- ); -} diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 4392173f2..fd725cf9f 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -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({
{label} - +
{value} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 6a0214972..fe55b5580 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -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() {
{t("insights.bloodGlucose.clinical.cv.label")} - +
@@ -285,7 +287,7 @@ function Metric({ {icon} {label} - {help ? : null} + {help ? : null} {value} diff --git a/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx b/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx index 8fef436b1..2b48239b4 100644 --- a/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx +++ b/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx @@ -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", () => { diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 062f9fa1d..b7add85ca 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -66,12 +66,16 @@ const Y_LABEL_KEY: Record = { 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 = { - 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 = { diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index 6074baf80..c710db552 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -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 = { - 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) => ( ))} diff --git a/src/components/insights/mood/mood-tag-breakdown.tsx b/src/components/insights/mood/mood-tag-breakdown.tsx index 763548820..2e4c3d5bc 100644 --- a/src/components/insights/mood/mood-tag-breakdown.tsx +++ b/src/components/insights/mood/mood-tag-breakdown.tsx @@ -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[] }) { diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index 481f25aa4..e1f96541a 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -47,11 +47,13 @@ const LABEL_KEYS: Record = { 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({ diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx index 3f72af6b4..a0d69225f 100644 --- a/src/components/insights/mood/mood-weekday-chart.tsx +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -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} diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index b413419e0..587721c73 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -94,7 +94,7 @@ export const STAGE_COLORS: Record = { 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 { diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 8799ad0e2..350478c1b 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -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" ? ( - + ) : undefined } /> @@ -75,7 +75,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { title={t("insights.sleep.debt.title")} right={ debt.source === "COMPUTED" ? ( - + ) : undefined } /> diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 6ca8783df..6bc216c64 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -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 = { - 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); diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 6cdfcf319..18cbaaec8 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -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 = { - 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); diff --git a/src/components/medications/medication-view-toggle.tsx b/src/components/medications/medication-view-toggle.tsx index e9e08d109..5f32c0e08 100644 --- a/src/components/medications/medication-view-toggle.tsx +++ b/src/components/medications/medication-view-toggle.tsx @@ -3,16 +3,13 @@ import { LayoutGrid, Table as TableIcon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; +import { ViewToggle } from "@/components/ui/view-toggle"; import type { MedicationListView } from "@/lib/medication-list-layout"; /** * v1.16.10 — icon-only card/table view toggle for the /medications page - * header. A small two-segment control: the active segment carries the - * filled surface, the inactive one stays ghost. `aria-pressed` announces - * the state per segment; the group is named for screen readers. Segments - * keep the 44-px mobile tap floor (`size-11`), shrinking to the standard - * 36-px control height on desktop like the neighbouring Add button. + * header. Thin wrapper over the shared `ViewToggle` primitive; see its + * docstring for the interaction grammar. */ interface MedicationViewToggleProps { view: MedicationListView; @@ -25,49 +22,24 @@ export function MedicationViewToggle({ }: MedicationViewToggleProps) { const { t } = useTranslations(); - const segments = [ - { - value: "cards" as const, - label: t("medications.viewCards"), - icon: LayoutGrid, - }, - { - value: "table" as const, - label: t("medications.viewTable"), - icon: TableIcon, - }, - ]; - return ( -
- {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
+ ); } diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 2b0ad486e..af57f203d 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -18,7 +18,7 @@ * `assessment-result`, `assessment-history`, `crisis-card`). * The landing follows the card grammar of Vorsorge / Medications: instrument * cards (last-result line + Start) + a history card. The disclaimer renders - * ONLY here (a muted caption) and behind the InfoHint — never while testing. + * ONLY here (a muted caption) and behind the InfoPopover — never while testing. * * v1.27.9 — the landing is intro + instrument cards, nothing else: the * combined history card left the main page entirely. Each card opens a diff --git a/src/components/module-list/module-view-toggle.tsx b/src/components/module-list/module-view-toggle.tsx index 2fc25c0cb..139a55845 100644 --- a/src/components/module-list/module-view-toggle.tsx +++ b/src/components/module-list/module-view-toggle.tsx @@ -3,15 +3,15 @@ import { LayoutGrid, List as ListIcon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; +import { ViewToggle } from "@/components/ui/view-toggle"; import type { ModuleListView } from "@/lib/module-list-prefs"; /** * v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the - * Vorsorge / Illness / Labs settings pages. Mirrors the medication module's - * `MedicationViewToggle` grammar (two ghost segments, the active one filled, - * `aria-pressed` per segment) but switches between a card grid and a compact - * list rather than cards-vs-table, so the label set is `moduleList.view*`. + * Vorsorge / Illness / Labs settings pages. Thin wrapper over the shared + * `ViewToggle` primitive (mirrors the medication module's + * `MedicationViewToggle`, which is the same wrapper switching between a + * card grid and a compact list rather than cards-vs-table). */ interface ModuleViewToggleProps { view: ModuleListView; @@ -21,49 +21,24 @@ interface ModuleViewToggleProps { export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) { const { t } = useTranslations(); - const segments = [ - { - value: "cards" as const, - label: t("moduleList.viewCards"), - icon: LayoutGrid, - }, - { - value: "list" as const, - label: t("moduleList.viewList"), - icon: ListIcon, - }, - ]; - return ( -
- {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
+ ); } diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx deleted file mode 100644 index d3caa2de9..000000000 --- a/src/components/ui/info-hint.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Info } from "lucide-react"; - -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -/** - * A small, tap-reachable help affordance. Replaces the bare `title=` - * attribute, which never surfaces on touch. The trigger is a real button - * with an accessible name, so keyboard and screen-reader users reach the - * same hint a pointer user gets on hover; tapping the button focuses it, - * which opens the tooltip on touch devices. - */ -export function InfoHint({ - label, - className, -}: { - /** The hint text shown inside the tooltip. */ - label: string; - className?: string; -}) { - const { t } = useTranslations(); - return ( - - - - - - - {label} - - - - ); -} diff --git a/src/components/ui/info-popover.tsx b/src/components/ui/info-popover.tsx new file mode 100644 index 000000000..549c8036a --- /dev/null +++ b/src/components/ui/info-popover.tsx @@ -0,0 +1,96 @@ +"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"; + +/** A plain external citation link rendered under the popover body. */ +export interface InfoPopoverLink { + /** Display name of the cited source (e.g. "WHO BMI classification"). */ + name: string; + /** Absolute URL, rendered as a plain ``, never HTML. */ + url: string; +} + +/** + * The app's one (i) info-affordance. Replaces the bare `title=` attribute + * (which never surfaces on touch) and, since v1.28.17, the earlier + * two-primitive split: a click-opened `Popover` — not a hover `Tooltip` — + * because most of this app's use is on touch, where a hover-only tooltip + * never opens reliably on tap. `v1.29.2`'s `VitalsInfoTip` made exactly + * this argument for the vitals grid; the same reasoning applies to every + * other (i) trigger in the tree, so the two primitives merge into this one. + * + * The trigger is a real button with an accessible name, so keyboard and + * screen-reader users reach the same content a pointer user gets on tap. + */ +export interface InfoPopoverProps { + /** Body content — plain text or a short mix of text nodes. */ + content: React.ReactNode; + /** Optional cited source, rendered as a plain external link. */ + link?: InfoPopoverLink; + /** + * Accessible name of the trigger button. Defaults to the generic + * "More information" — pass a more specific label (e.g. "How is this + * calculated?") where the caller wants that framing. + */ + label?: string; + className?: string; +} + +export function InfoPopover({ + content, + link, + label, + className, +}: InfoPopoverProps) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + + return ( + + + + + +

{content}

+ {link ? ( +
+ {link.name} + + ) : null} + + + ); +} diff --git a/src/components/ui/view-toggle.tsx b/src/components/ui/view-toggle.tsx new file mode 100644 index 000000000..88c76eab9 --- /dev/null +++ b/src/components/ui/view-toggle.tsx @@ -0,0 +1,77 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export interface ViewToggleSegment { + value: T; + label: string; + icon: LucideIcon; +} + +export interface ViewToggleProps { + view: T; + onChange: (view: T) => void; + segments: ViewToggleSegment[]; + /** Accessible name for the segmented-control group. */ + groupLabel: string; + /** `data-slot` prefix per segment, rendered as `${dataSlotPrefix}-${value}`. */ + dataSlotPrefix: string; +} + +/** + * Shared icon-only segmented view toggle (e.g. cards vs table/list). A small + * two-segment control: the active segment carries the filled surface, the + * inactive one stays ghost. `aria-pressed` announces the state per segment; + * the group is named for screen readers. Segments keep the 44-px mobile tap + * floor (`size-11` via `size-10` + the `sm:size-8` desktop step), shrinking + * to the standard 36-px control height on desktop like a neighbouring Add + * button. + * + * Previously forked between the medications module + * (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs + * settings pages (`module-list/module-view-toggle.tsx`) — verbatim + * copy-paste that only diverged in icons + i18n keys and had already + * drifted. Both are now thin wrappers over this one primitive. + */ +export function ViewToggle({ + view, + onChange, + segments, + groupLabel, + dataSlotPrefix, +}: ViewToggleProps) { + return ( +
+ {segments.map(({ value, label, icon: Icon }) => { + const active = view === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/workouts/sport-icons.ts b/src/lib/workouts/sport-icons.ts new file mode 100644 index 000000000..f126579a5 --- /dev/null +++ b/src/lib/workouts/sport-icons.ts @@ -0,0 +1,45 @@ +import { + Activity, + Bike, + Dumbbell, + Footprints, + HeartPulse, + Mountain, + PersonStanding, + type LucideIcon, +} from "lucide-react"; + +/** + * Shared sport-type → icon map. Previously three private copies + * (`workout-list.tsx`, `workout-detail.tsx`, `recent-workouts-tile.tsx`) + * that had already drifted — `mixedCardio` rendered `Heart` in the list but + * `HeartPulse` in the detail view and the dashboard tile, so the same + * workout showed two different glyphs between the row and its detail page. + * `HeartPulse` won as the shared value (two of three sites already agreed). + */ +export const SPORT_TYPE_ICON: Record = { + 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, +}; + +export function iconForSport(sportType: string): LucideIcon { + return SPORT_TYPE_ICON[sportType] ?? Activity; +}