mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
ux: i18n leaks, focus rings, tap-target floor, admin chip-scroll, empty-state CTA vocabulary
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.
i18n leaks (3 primitives):
- Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
so a German user heard 'Close, button' on every modal. Both lift
`useTranslations()` and read `common.close`.
- ChartSkeleton's sr-only loading announcement was English-only.
The component is already a client island; reads `charts.loadingLabel`
from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
- Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
was hardcoded. Routes through `t("nav.breadcrumb")` with a new
key in each locale.
Focus rings (mobile + tablet nav):
- Bottom-nav primary links + the More overflow trigger relied on a
`text-muted-foreground` → `text-primary` colour transition only;
keyboard / switch-control users saw no focus indicator at all.
Added `focus-visible:ring-ring/50 focus-visible:ring-2
focus-visible:outline-none focus-visible:ring-offset-2` matching
the v1.4.33 sidebar treatment.
- Mobile top-bar trigger had `focus:outline-none` without a
`focus-visible` replacement. Same treatment applied.
Tap-target floor (WCAG 2.5.5):
- Input / NativeSelect / Select / DateTimeInput were locked to
h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
mobile clears the 44 px floor while desktop keeps the 40 px norm
that the Button primitive already converged on in v1.4.33.
Admin chip-strip auto-scroll-into-view:
- The admin shell shipped 13 chips but lacked the
`scrollIntoView({inline: 'center'})` effect the settings shell
got in v1.4.33. Admins tapping a chip past the right edge landed
on the new page with the strip scrolled to the left and the
active chip off-screen. Copied the settings-shell pattern verbatim.
List-page subtitles on mobile:
- Measurements / Mood / Medications pages hid their descriptive
subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
the subtitle on mobile because it doubles as contextual help.
Dropped the hide rule; the H1 still anchors the visual.
Insights empty-state CTA vocabulary:
- `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
`TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
`MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
empty-state CTAs emitted those mismatched tokens and the dialog
silently failed to open. Replaced with a single source of truth
derived from `MEASUREMENT_TYPES` plus a normalisation map for
legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
New unit test walks every `?add=` token emitted under
`src/app/insights/**` and asserts it resolves to a real form type.
This commit is contained in:
1 parent
6885703505
commit
1768b1d971
22 files changed
+235
-39
No files matched your search
@@ -156,6 +156,26 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
const { t } = useTranslations();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so
|
||||
// the active chip stays in view when the user lands on a deeper
|
||||
// admin route. Without it the 16-chip strip stays scrolled to the
|
||||
// left and the active chip lives off-screen, reading as a broken nav.
|
||||
const mobileStripRef = React.useRef<HTMLElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const strip = mobileStripRef.current;
|
||||
if (!strip) return;
|
||||
const activeChip = strip.querySelector<HTMLElement>(
|
||||
'[aria-current="page"]',
|
||||
);
|
||||
if (!activeChip) return;
|
||||
activeChip.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
// already, so this inner shell only carries the wider max-width.
|
||||
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
|
||||
@@ -169,13 +189,19 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
horizontal swipe + keyboard arrow scrolling still work, the
|
||||
bar just doesn't draw. Without this the 13-section strip
|
||||
would render an always-on scrollbar that the maintainer kept
|
||||
mis-attributing to the api-tokens table below it. */}
|
||||
mis-attributing to the api-tokens table below it.
|
||||
|
||||
v1.4.34 IW-G — `snap-x snap-mandatory` plus per-chip
|
||||
`snap-start` lets a swipe-flick land on the next chip's
|
||||
leading edge instead of the in-between dead zone, matching
|
||||
the settings-shell polish. */}
|
||||
<nav
|
||||
ref={mobileStripRef}
|
||||
aria-label={t("admin.shell.sectionsNav")}
|
||||
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
|
||||
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
<li>
|
||||
<li className="snap-start">
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={activeSlug === null ? "page" : undefined}
|
||||
@@ -197,7 +223,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<li key={section.slug} className="snap-start">
|
||||
<Link
|
||||
href={`/admin/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3d (R1.2 H4) — layout-stable loading shell for every
|
||||
@@ -27,6 +28,7 @@ import { cn } from "@/lib/utils";
|
||||
* `motion-reduce:animate-none` modifier.
|
||||
*/
|
||||
export function ChartSkeleton({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-skeleton"
|
||||
@@ -38,7 +40,9 @@ export function ChartSkeleton({ className }: { className?: string }) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading chart…</span>
|
||||
{/* v1.4.34 IW-G — sr-only announcement now reads the locale's
|
||||
`charts.loadingLabel` key instead of literal English. */}
|
||||
<span className="sr-only">{t("charts.loadingLabel")}</span>
|
||||
|
||||
{/* Header row — title + range tabs match the real chart's
|
||||
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
|
||||
|
||||
@@ -99,8 +99,13 @@ export function BottomNav() {
|
||||
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
|
||||
// outer min-h-11 min-w-11 is the actual hit area; the icon
|
||||
// stays visually centered at 20px so the design doesn't shift.
|
||||
//
|
||||
// v1.4.34 IW-G — keyboard users get a visible ring on
|
||||
// focus-visible, matching the sidebar treatment from
|
||||
// v1.4.33. Without it the bottom-nav had no focus
|
||||
// indicator at all on Tab navigation.
|
||||
className={cn(
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
@@ -121,7 +126,7 @@ export function BottomNav() {
|
||||
aria-current={overflowActive ? "page" : undefined}
|
||||
onClick={() => setMoreOpen(true)}
|
||||
className={cn(
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
|
||||
overflowActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
|
||||
@@ -64,7 +64,11 @@ export function TopBar() {
|
||||
aria-label={t("nav.userMenu")}
|
||||
// v1.4.25 W8 — hit the WCAG 2.5.5 44 px touch-target floor on
|
||||
// mobile. The text-only py-1.5 was previously ~30 px tall.
|
||||
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus:outline-none"
|
||||
//
|
||||
// v1.4.34 IW-G — keyboard users get a visible ring on
|
||||
// focus-visible. The previous `focus:outline-none` killed
|
||||
// the focus indicator without replacing it.
|
||||
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{user.username}</span>
|
||||
|
||||
@@ -87,8 +87,45 @@ const MEASUREMENT_TYPES = [
|
||||
unit: "%",
|
||||
placeholder: "98",
|
||||
},
|
||||
{
|
||||
value: "BODY_TEMPERATURE",
|
||||
labelKey: "measurements.typeBodyTemperature",
|
||||
unit: "°C",
|
||||
placeholder: "36.6",
|
||||
},
|
||||
] as const;
|
||||
|
||||
// v1.4.34 IW-G — single source of truth for the `/measurements?add=<TYPE>`
|
||||
// deep link the Insights empty-state CTAs ship. Derived from the form's
|
||||
// MEASUREMENT_TYPES so a new row in the form is immediately usable as
|
||||
// a deep-link target.
|
||||
export const MEASUREMENT_FORM_TYPE_VALUES = MEASUREMENT_TYPES.map(
|
||||
(t) => t.value,
|
||||
) as readonly string[];
|
||||
|
||||
// Legacy / Insights-internal tokens that predate the canonical enum.
|
||||
// Older empty-state CTAs and a handful of dashboard tiles still emit
|
||||
// these — translate them to the form's canonical value so the link
|
||||
// keeps working without forcing every caller to rename in lockstep.
|
||||
export const ADD_TOKEN_ALIASES: Readonly<Record<string, string>> = {
|
||||
GLUCOSE: "BLOOD_GLUCOSE",
|
||||
TEMPERATURE: "BODY_TEMPERATURE",
|
||||
HEART_RATE: "PULSE",
|
||||
BMI: "WEIGHT",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a `?add=<token>` deep-link value to a real form type, or
|
||||
* `null` when the token has no canonical mapping. Centralised so the
|
||||
* page-level dispatcher and the F-1 contract test consume the same
|
||||
* resolver.
|
||||
*/
|
||||
export function resolveAddToken(token: string | null | undefined): string | null {
|
||||
if (!token) return null;
|
||||
const aliased = ADD_TOKEN_ALIASES[token] ?? token;
|
||||
return MEASUREMENT_FORM_TYPE_VALUES.includes(aliased) ? aliased : null;
|
||||
}
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
{ value: "FASTING", labelKey: "measurements.glucoseContextFasting" },
|
||||
{
|
||||
|
||||
@@ -46,7 +46,10 @@ export function NotificationsSection() {
|
||||
inbox at `/notifications`. Both surfaces used to be named plain
|
||||
"Notifications", so the crumb spells out where the user is and
|
||||
offers a one-tap jump to the inbox. */}
|
||||
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
|
||||
<nav
|
||||
aria-label={t("nav.breadcrumb")}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
<ol className="flex items-center gap-1">
|
||||
<li>{t("notifications.breadcrumbSettings")}</li>
|
||||
<li aria-hidden="true">
|
||||
|
||||
@@ -29,8 +29,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* even when iOS shrinks its placeholder, so the field looks
|
||||
* identical on both engines.
|
||||
*/
|
||||
// v1.4.34 IW-G — match the `<Input>` mobile floor (44 px) so date and
|
||||
// datetime fields don't sit below the WCAG 2.5.5 target-size minimum
|
||||
// on Pixel-5-class viewports. Desktop falls back to the 40 px norm.
|
||||
const DATE_INPUT_HEIGHT_CLASSES =
|
||||
"appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
|
||||
"appearance-none min-h-11 h-11 sm:min-h-10 sm:h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
|
||||
|
||||
type InputProps = React.ComponentProps<typeof Input>;
|
||||
type DateInputProps = Omit<InputProps, "type" | "lang">;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -55,6 +56,7 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
@@ -84,7 +86,9 @@ function DialogContent({
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
{/* v1.4.34 IW-G — read `common.close` so screen readers
|
||||
hear the localised label instead of literal English. */}
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
@@ -110,6 +114,7 @@ function DialogFooter({
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
@@ -122,7 +127,7 @@ function DialogFooter({
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t("common.close")}</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,9 @@ function Input({
|
||||
data-lpignore={skipAutofill ? "true" : undefined}
|
||||
data-1p-ignore={skipAutofill ? "true" : undefined}
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
|
||||
// shrink to 40 px on sm+ where pointer precision is higher.
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 sm:h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -35,8 +35,10 @@ import { cn } from "@/lib/utils";
|
||||
* uniform with the rest of the form's `<Input>` + `<SelectTrigger>`
|
||||
* rows.
|
||||
*/
|
||||
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; shrink
|
||||
// to 40 px on sm+ where the pointer precision is higher.
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
||||
|
||||
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
// v1.4.34 IW-G — `size=default` floors at 44 px on mobile to
|
||||
// clear WCAG 2.5.5 and shrinks to 40 px on sm+ to match the
|
||||
// desktop input rhythm. `size=sm` (filter chips) intentionally
|
||||
// keeps the 32 px footprint.
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
@@ -54,6 +55,7 @@ function SheetContent({
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
@@ -86,7 +88,9 @@ function SheetContent({
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
{/* v1.4.34 IW-G — read `common.close` so screen readers
|
||||
hear the localised label instead of literal English. */}
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
|
||||
Reference in new issue
Block a user