From a2e24b12407b498602baca698b4b831b64eb111f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 22:13:50 +0200 Subject: [PATCH] a11y(insights): polish sticky section nav (aria-current, focus, motion-reduce) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple accessibility + visual defects on ``: - 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) --- messages/de.json | 1 + messages/en.json | 1 + src/app/insights/page.tsx | 93 +++++++++++++++++++++++++++------------ 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2ca08a6d2..18c580a18 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 635d9b946..d6456bcc5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 38e716291..a59f2b19f 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -1017,7 +1017,7 @@ export default function InsightsPage() { legacyPayload={advisor.payload?.legacyPayload ?? false} /> -
+

{t("insights.generalStatusTitle")} @@ -1041,7 +1041,7 @@ export default function InsightsPage() {

{/* Section 3: Blood pressure */} -
+

{t("insights.bloodPressureSectionTitle")} @@ -1255,7 +1255,7 @@ export default function InsightsPage() {

{/* Section 4: Weight */} -
+

{t("insights.weightSectionTitle")} @@ -1425,7 +1425,7 @@ export default function InsightsPage() {

{/* Section 5: Pulse */} -
+

{t("insights.pulseSectionTitle")} @@ -1460,7 +1460,7 @@ export default function InsightsPage() { {/* Section: Mood */} {showMoodSection && ( -
+

{t("insights.moodSectionTitle")} @@ -1488,7 +1488,7 @@ export default function InsightsPage() { )} {/* Section 6: Medication Compliance */} -
+

{t("insights.medicationCompliance")} @@ -1589,7 +1589,7 @@ export default function InsightsPage() {

{/* Section 7: BMI */} -
+

{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 ( -