a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce)

Multiple accessibility + visual defects on `<InsightsSectionNav>`:

- No `aria-current` on the active tab — screen readers had no signal
  the active section had changed.
- No `:focus-visible` ring — `transition-colors` only animated the
  background, leaving keyboard focus invisible against `bg-primary/10`.
- Unconditional `scroll-smooth` ignored `prefers-reduced-motion`.
- `bg-background/80` let the hero gradient bleed through.
- `-mx-4` carried the bar past the safe horizontal-scroll boundary
  on 280 px Galaxy Fold; combined with the inner `overflow-x-auto`
  it produced a ghost scrollbar.
- 112 px `scroll-mt-28` was ~2.5× the actual sticky-strip height
  (~40-44 px), pushing target headings well below the viewport top.
- IntersectionObserver picked the last entry in the batch instead of
  the highest intersectionRatio, so the active pill jumped during
  fast scrolls.

Add `aria-current="location"`, focus-visible ring, motion-reduce
gate around `scrollIntoView`, swap `bg-background/80` for `/95
backdrop-blur`, drop the negative margin (the parent handles
horizontal padding), hide the inner scrollbar, ratio-sort the
observer entries, and tune `scroll-mt-28` → `scroll-mt-16` on all
seven sections.

Add `insights.navAriaLabel` (EN: "Skip to section", DE: "Zu
Abschnitt springen").

Closes Design-H1, Design-H3, Code-MED-4, Design-LOW-4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 22:13:50 +02:00
1 parent 2075d392b1
commit a2e24b1240
3 files changed
+68 -27

No files matched your search

+1
View File
@@ -901,6 +901,7 @@
"navMood": "Stimmung",
"navMedication": "Medikamente",
"navBmi": "BMI",
"navAriaLabel": "Zu Abschnitt springen",
"measurementsOverDays": "{count} Messungen über {days} Tage",
"healthStatus": "Gesundheitsstatus",
"noWeightData": "Keine Gewichtsdaten oder Größe nicht hinterlegt.",
+1
View File
@@ -901,6 +901,7 @@
"navMood": "Mood",
"navMedication": "Medication",
"navBmi": "BMI",
"navAriaLabel": "Skip to section",
"measurementsOverDays": "{count} measurements over {days} days",
"healthStatus": "Health Status",
"noWeightData": "No weight data or height not set.",
+66 -27
View File
@@ -1017,7 +1017,7 @@ export default function InsightsPage() {
legacyPayload={advisor.payload?.legacyPayload ?? false}
/>
<section id="section-general" className="scroll-mt-28 space-y-2">
<section id="section-general" className="scroll-mt-16 space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.generalStatusTitle")}
@@ -1041,7 +1041,7 @@ export default function InsightsPage() {
</section>
{/* Section 3: Blood pressure */}
<section id="section-bp" className="scroll-mt-28 space-y-4">
<section id="section-bp" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.bloodPressureSectionTitle")}
@@ -1255,7 +1255,7 @@ export default function InsightsPage() {
</section>
{/* Section 4: Weight */}
<section id="section-weight" className="scroll-mt-28 space-y-4">
<section id="section-weight" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.weightSectionTitle")}
@@ -1425,7 +1425,7 @@ export default function InsightsPage() {
</section>
{/* Section 5: Pulse */}
<section id="section-pulse" className="scroll-mt-28 space-y-4">
<section id="section-pulse" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.pulseSectionTitle")}
@@ -1460,7 +1460,7 @@ export default function InsightsPage() {
{/* Section: Mood */}
{showMoodSection && (
<section id="section-mood" className="scroll-mt-28 space-y-4">
<section id="section-mood" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.moodSectionTitle")}
@@ -1488,7 +1488,7 @@ export default function InsightsPage() {
)}
{/* Section 6: Medication Compliance */}
<section id="section-meds" className="scroll-mt-28 space-y-4">
<section id="section-meds" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.medicationCompliance")}
@@ -1589,7 +1589,7 @@ export default function InsightsPage() {
</section>
{/* Section 7: BMI */}
<section id="section-bmi" className="scroll-mt-28 space-y-4">
<section id="section-bmi" className="scroll-mt-16 space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{t("insights.bmiSectionTitle")}
@@ -1729,10 +1729,19 @@ function InsightsSectionNav() {
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
// v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the
// highest intersectionRatio in the band rather than the
// observer-supplied last entry. Three sections briefly
// visible during a fast scroll otherwise made the active
// pill jump to whichever one came last in the batch.
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
const top = visible[0];
if (top) {
setActiveId((current) =>
current === top.target.id ? current : top.target.id,
);
}
},
{ rootMargin: "-30% 0px -60% 0px" },
@@ -1748,26 +1757,56 @@ function InsightsSectionNav() {
function scrollTo(id: string) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth" });
if (!el) return;
// v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind
// `prefers-reduced-motion`; honour the user's OS-level pref.
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({
behavior: prefersReducedMotion ? "auto" : "smooth",
});
}
return (
<nav className="bg-background/80 sticky top-0 z-30 -mx-4 overflow-x-auto border-b px-4 py-2 backdrop-blur-sm md:-mx-6 md:px-6">
// v1.4.22 W5 reconcile (Design-H1, Design-H3) — accessibility +
// sticky-strip polish. Notable changes:
// - aria-label so screen-reader landmark traversal hears the
// rail's purpose ("Skip to section" / "Zu Abschnitt springen").
// - bg-background/95 instead of /80 to kill the hero-glow bleed
// during scroll.
// - Drop the `-mx-4 / md:-mx-6` negative margin; the parent
// container handles horizontal padding so 280px (Galaxy Fold)
// doesn't get a ghost scrollbar.
// - Hide the inner overflow's scrollbar so the sticky strip
// reads as a single bar.
<nav
aria-label={t("insights.navAriaLabel")}
className={cn(
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
<div className="flex gap-2">
{SECTION_IDS.map((id) => (
<button
key={id}
onClick={() => scrollTo(id)}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
activeId === id
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(SECTION_LABEL_KEYS[id])}
</button>
))}
{SECTION_IDS.map((id) => {
const isActive = activeId === id;
return (
<button
key={id}
onClick={() => scrollTo(id)}
aria-current={isActive ? "location" : undefined}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(SECTION_LABEL_KEYS[id])}
</button>
);
})}
</div>
</nav>
);