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

-60
View File
@@ -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 (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="info-hint"
aria-label={t("common.moreInfo")}
className={cn(
// A 44px (WCAG 2.5.5) tap target whose visible footprint stays at
// the 24px the icon needs: the 44px box is offset by an equal
// negative margin (`p-2.5` + `-m-2.5`), so surrounding inline
// layout is unchanged while the touchable area meets the floor.
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-2.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full p-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="info-hint-body"
align="start"
className="max-w-xs leading-relaxed"
>
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+96
View File
@@ -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 `<a>`, 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="info-popover-trigger"
aria-label={label ?? t("common.moreInfo")}
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="info-popover-body"
align="end"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p className="text-muted-foreground text-xs leading-snug">{content}</p>
{link ? (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary block text-xs leading-snug hover:underline"
>
{link.name}
</a>
) : null}
</PopoverContent>
</Popover>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ViewToggleSegment<T extends string> {
value: T;
label: string;
icon: LucideIcon;
}
export interface ViewToggleProps<T extends string> {
view: T;
onChange: (view: T) => void;
segments: ViewToggleSegment<T>[];
/** 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<T extends string>({
view,
onChange,
segments,
groupLabel,
dataSlotPrefix,
}: ViewToggleProps<T>) {
return (
<div
role="group"
aria-label={groupLabel}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
>
{segments.map(({ value, label, icon: Icon }) => {
const active = view === value;
return (
<button
key={value}
type="button"
onClick={() => {
if (!active) onChange(value);
}}
aria-pressed={active}
aria-label={label}
title={label}
data-slot={`${dataSlotPrefix}-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}