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:
Marc-André Bombeck committed 2026-05-17 00:34:00 +02:00
1 parent 6885703505
commit 1768b1d971
22 files changed
+235 -39

No files matched your search

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import {
MEASUREMENT_FORM_TYPE_VALUES,
ADD_TOKEN_ALIASES,
resolveAddToken,
} from "@/components/measurements/measurement-form";
/**
* F-1 contract — every `?add=<token>` the Insights surfaces ship must
* either land on a real `<MeasurementForm>` row or be an explicit
* legacy alias mapped to one. Without this guard the empty-state CTA
* silently drops the deep link and the dialog never opens — the
* symptom that surfaced as "the Körpertemperatur CTA does nothing".
*/
describe("measurements ?add= deep link", () => {
const repoRoot = join(__dirname, "..", "..", "..", "..");
const insightsRoot = join(repoRoot, "src", "app", "insights");
const insightsComponentsRoot = join(repoRoot, "src", "components", "insights");
// Captures both `href="/measurements?add=FOO"` literals and the
// dynamic `?add=${prop}` template the shared HealthKit metric page
// uses. The latter is covered by walking `emptyStateCtaType="…"` in
// every `/insights/<slug>/page.tsx`.
const literalAddPattern = /\/measurements\?add=([A-Z_]+)/g;
const ctaTypePattern = /emptyStateCtaType=\{?["']([A-Z_]+)["']/g;
function collectFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "__tests__" || entry === "node_modules") continue;
out.push(...collectFiles(full));
} else if (entry.endsWith(".tsx") || entry.endsWith(".ts")) {
out.push(full);
}
}
return out;
}
const files = [
...collectFiles(insightsRoot),
...collectFiles(insightsComponentsRoot),
];
const emittedTokens = new Set<string>();
for (const file of files) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(literalAddPattern)) {
emittedTokens.add(match[1]);
}
for (const match of source.matchAll(ctaTypePattern)) {
emittedTokens.add(match[1]);
}
}
it("walks at least one `?add=` token under src/app/insights", () => {
// Sanity guard so the regex collector doesn't silently zero out.
expect(emittedTokens.size).toBeGreaterThan(0);
});
it("every emitted `?add=` token resolves to a real form type", () => {
const unresolved: string[] = [];
for (const token of emittedTokens) {
if (resolveAddToken(token) === null) unresolved.push(token);
}
expect(unresolved).toEqual([]);
});
it("legacy aliases each map to a real form type", () => {
for (const [legacy, canonical] of Object.entries(ADD_TOKEN_ALIASES)) {
expect(MEASUREMENT_FORM_TYPE_VALUES).toContain(canonical);
expect(resolveAddToken(legacy)).toBe(canonical);
}
});
it("resolves unknown tokens to null without throwing", () => {
expect(resolveAddToken("NOT_A_TYPE")).toBeNull();
expect(resolveAddToken(null)).toBeNull();
expect(resolveAddToken(undefined)).toBeNull();
expect(resolveAddToken("")).toBeNull();
});
});
+15 -21
View File
@@ -2,7 +2,10 @@
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import {
MeasurementForm,
resolveAddToken,
} from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -10,23 +13,6 @@ import { Plus, Loader2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
// v1.4.27 MB6 — the insights empty-state CTAs link here with a
// `?add=<TYPE>` query param to auto-open the add-measurement dialog
// with the right default type. Keep the allow-list aligned with
// MEASUREMENT_TYPES inside `measurement-form.tsx`; an unknown value
// is dropped silently so a stale link can never trap the user on a
// broken form.
const ALLOWED_ADD_TYPES = new Set([
"BLOOD_PRESSURE",
"WEIGHT",
"PULSE",
"BMI",
"GLUCOSE",
"TEMPERATURE",
"BODY_FAT",
"HEART_RATE",
]);
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
@@ -52,8 +38,13 @@ export default function MeasurementsPage() {
if (addParam && addParam !== consumedAddParam) {
setConsumedAddParam(addParam);
if (ALLOWED_ADD_TYPES.has(addParam)) {
setDefaultType(addParam);
// v1.4.34 IW-G — `resolveAddToken` is the single source of truth
// for legacy aliases (`GLUCOSE`, `TEMPERATURE`, …) plus the
// canonical form values. Unknown tokens still drop silently so a
// stale link cannot trap the user on a broken form.
const resolved = resolveAddToken(addParam);
if (resolved) {
setDefaultType(resolved);
setDialogOpen(true);
}
// Drop the query string so the back-button leaves the user on
@@ -82,7 +73,10 @@ export default function MeasurementsPage() {
<h1 className="text-2xl font-bold tracking-tight">
{t("measurements.title")}
</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
the H1 isn't an unframed label. `text-xs sm:text-sm`
preserves the desktop hierarchy. */}
<p className="text-muted-foreground text-xs sm:text-sm">
{t("measurements.subtitle")}
</p>
</div>
+3 -1
View File
@@ -154,7 +154,9 @@ export default function MedicationsPage() {
<h1 className="text-2xl font-bold tracking-tight">
{t("medications.title")}
</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground text-xs sm:text-sm">
{t("medications.subtitle")}
</p>
</div>
+3 -1
View File
@@ -42,7 +42,9 @@ export default function MoodPage() {
<h1 className="text-2xl font-bold tracking-tight">
{t("mood.title")}
</h1>
<p className="text-muted-foreground hidden text-sm sm:block">
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground text-xs sm:text-sm">
{t("mood.subtitle")}
</p>
</div>