mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
1ff179178f
commit
247bc4f33c
25 files changed
+401
-400
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user