From 107a111545ad46aaaaa89c6589940e0442972ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 17:31:40 +0200 Subject: [PATCH] fix(insights): polish range pills, zero-delta caption, scrollbar gutter, mood tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Range pills: make the radiogroup a single tab stop with roving tabindex and Arrow / Home / End key handling, matching the WAI-ARIA radiogroup pattern. Previously every pill was its own tab stop and the arrow keys were dead. - Range delta caption: render a neutral dash + "no change" for a perfectly stable metric instead of a down-arrow with "−0%". Adds the `noChange` string across all six locales. - Scrollbar gutter: scope the document-level `scrollbar-gutter: stable` to the body-scrolled shells (login / register / onboarding / legal) via a `data-scroll` attribute the shell sets, so the height-locked authenticated branch no longer reserves a second, never-painted gutter on classic scrollbar platforms. - Mood tiles: a lone in-target / stability tile now spans full width instead of leaving a half-width orphan beside it. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 12b434a0c262e29ced1ac0aaf631db6678c3ad05) --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/app/globals.css | 23 +++++--- .../__tests__/metric-range-delta.test.tsx | 57 +++++++++++++++++++ .../__tests__/time-range-pills.test.tsx | 51 +++++++++++++++++ .../insights/metric-range-delta.tsx | 24 +++++++- .../insights/mood/mood-insights-sections.tsx | 18 +++++- src/components/insights/time-range-pills.tsx | 47 ++++++++++++++- src/components/layout/auth-shell.tsx | 20 +++++++ 13 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 src/components/insights/__tests__/metric-range-delta.test.tsx create mode 100644 src/components/insights/__tests__/time-range-pills.test.tsx diff --git a/messages/de.json b/messages/de.json index 9f54ea098..32dd32d17 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1624,7 +1624,8 @@ "30d": "ggü. vorherigen 30T", "90d": "ggü. vorherigen 90T", "1y": "ggü. Vorjahr" - } + }, + "noChange": "keine Änderung" }, "correlationRow": { "title": "Zusammenhänge", diff --git a/messages/en.json b/messages/en.json index fa9cee5e1..07974000f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1624,7 +1624,8 @@ "30d": "vs prior 30d", "90d": "vs prior 90d", "1y": "vs prior year" - } + }, + "noChange": "no change" }, "correlationRow": { "title": "Relationships", diff --git a/messages/es.json b/messages/es.json index 7cfc7c4f1..ce163e91d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1624,7 +1624,8 @@ "30d": "vs 30d anteriores", "90d": "vs 90d anteriores", "1y": "vs año anterior" - } + }, + "noChange": "sin cambios" }, "correlationRow": { "title": "Correlaciones", diff --git a/messages/fr.json b/messages/fr.json index 14f73716a..07d74a4ce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1624,7 +1624,8 @@ "30d": "vs 30j précédents", "90d": "vs 90j précédents", "1y": "vs année précédente" - } + }, + "noChange": "aucun changement" }, "correlationRow": { "title": "Corrélations", diff --git a/messages/it.json b/messages/it.json index fe11229cf..59c1d98bb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1624,7 +1624,8 @@ "30d": "vs 30g precedenti", "90d": "vs 90g precedenti", "1y": "vs anno precedente" - } + }, + "noChange": "nessuna variazione" }, "correlationRow": { "title": "Correlazioni", diff --git a/messages/pl.json b/messages/pl.json index d43c1ef43..3d5f69844 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1624,7 +1624,8 @@ "30d": "vs poprzednie 30d", "90d": "vs poprzednie 90d", "1y": "vs poprzedni rok" - } + }, + "noChange": "bez zmian" }, "correlationRow": { "title": "Korelacje", diff --git a/src/app/globals.css b/src/app/globals.css index 192ee4610..cc0895e5b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -253,16 +253,21 @@ @apply border-border outline-ring/50; } /* - * Reserve the scrollbar gutter on the document root so the body-scrolled - * shells (login / register / onboarding / standalone legal pages) keep a - * fixed content width whether or not the page overflows. Without it a tall - * page paints the bar and a short one hides it, and any `mx-auto` content - * recentres a few px sideways on navigation. The authenticated app shell - * scrolls inside its own `
` (`flex h-dvh` locks the body height, so - * the document never scrolls there) and reserves its own gutter — no - * double gutter results. + * Reserve the scrollbar gutter on the document root ONLY for the + * body-scrolled shells (login / register / onboarding / standalone legal + * pages), where the document itself overflows. Those keep a fixed content + * width whether or not the page paints the bar, so any `mx-auto` content + * does not recentre a few px sideways on navigation. `AuthShell` sets + * `data-scroll="body"` on `` for exactly those branches. + * + * The authenticated app shell is height-locked (`flex h-dvh`) and scrolls + * inside its own `
`, which reserves its own gutter. Applying this + * rule globally there reserved a SECOND, never-painted gutter on classic + * (non-overlay) scrollbar platforms (Windows / Linux) — a ~15px dead band. + * Scoping it to the body-scrolled branch leaves exactly one gutter per + * branch. */ - html { + html[data-scroll="body"] { scrollbar-gutter: stable; } body { diff --git a/src/components/insights/__tests__/metric-range-delta.test.tsx b/src/components/insights/__tests__/metric-range-delta.test.tsx new file mode 100644 index 000000000..dd5006a09 --- /dev/null +++ b/src/components/insights/__tests__/metric-range-delta.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { MetricRangeDelta } from "../metric-range-delta"; +import type { AnalyticsRangeData } from "@/hooks/use-analytics-range"; + +/** + * v1.9.0 QA (Design M1 / Correctness L-1) — a perfectly stable metric + * (delta === 0 / rounded 0 %) must read neutral: a flat dash and "no + * change", never a down-arrow with "−0%". + */ +function render(data: AnalyticsRangeData | undefined) { + return renderToStaticMarkup( + + + , + ); +} + +const base: AnalyticsRangeData = { + range: "30d", + windowDays: 30, + granularity: "DAY", + current: { count: 1, min: 1, max: 1, mean: 1, sum: 1 }, + previous: { count: 1, min: 1, max: 1, mean: 1, sum: 1 }, + delta: 0, + deltaPct: 0, +}; + +describe("MetricRangeDelta — zero-delta neutral", () => { + it("renders neutral copy + no directional arrow for a zero delta", () => { + const html = render(base); + expect(html).toContain('data-direction="neutral"'); + expect(html).toContain("no change"); + // No misleading "−0%". + expect(html).not.toContain("0%"); + expect(html).not.toContain("−"); + }); + + it("still renders a directional +pct for a real increase", () => { + const html = render({ ...base, delta: 2, deltaPct: 0.1 }); + expect(html).toContain('data-direction='); + expect(html).not.toContain('data-direction="neutral"'); + expect(html).toContain("+10%"); + }); + + it("shows the no-prior-data line when the comparison is null", () => { + const html = render({ ...base, delta: null, deltaPct: null }); + expect(html).toContain("metric-range-delta-empty"); + }); +}); diff --git a/src/components/insights/__tests__/time-range-pills.test.tsx b/src/components/insights/__tests__/time-range-pills.test.tsx new file mode 100644 index 000000000..0138a7f8e --- /dev/null +++ b/src/components/insights/__tests__/time-range-pills.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { TimeRangePills } from "../time-range-pills"; +import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; + +/** + * v1.9.0 QA (Design H1) — the range pills are a WAI-ARIA APG radiogroup, so + * the group must be a SINGLE tab stop: only the checked radio carries + * `tabIndex={0}`, every other radio is `-1` (roving tabindex). Before the + * fix all four were native buttons (four tab stops) with no arrow-key + * handler. Project convention is SSR-only tests, so we assert the rendered + * roving-tabindex contract + radio semantics rather than driving live key + * events. + */ +function render(value: (typeof ANALYTICS_RANGES)[number]) { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe("TimeRangePills — roving tabindex (single tab stop)", () => { + it("renders a radiogroup with one radio per range", () => { + const html = render("30d"); + expect(html).toContain('role="radiogroup"'); + const radios = html.match(/role="radio"/g) ?? []; + expect(radios).toHaveLength(ANALYTICS_RANGES.length); + }); + + it("makes only the checked radio tabbable (tabindex 0); the rest are -1", () => { + const html = render("30d"); + // Exactly one tabindex=0 (the checked radio is the single tab stop). + const tabbable = html.match(/tabindex="0"/g) ?? []; + expect(tabbable).toHaveLength(1); + // The remaining three are removed from the tab order. + const untabbable = html.match(/tabindex="-1"/g) ?? []; + expect(untabbable).toHaveLength(ANALYTICS_RANGES.length - 1); + }); + + it("marks the chosen value selected (aria-checked + data-selected)", () => { + const html = render("90d"); + // Exactly one radio is checked / selected. + expect(html.match(/aria-checked="true"/g) ?? []).toHaveLength(1); + expect(html.match(/data-selected="true"/g) ?? []).toHaveLength(1); + // The chosen pill is present and selected. + expect(html).toMatch(/data-range="90d"/); + }); +}); diff --git a/src/components/insights/metric-range-delta.tsx b/src/components/insights/metric-range-delta.tsx index d8ee4e72a..070e71eec 100644 --- a/src/components/insights/metric-range-delta.tsx +++ b/src/components/insights/metric-range-delta.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowDown, ArrowUp } from "lucide-react"; +import { ArrowDown, ArrowUp, Minus } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -62,8 +62,28 @@ export function MetricRangeDelta({ const direction = getTrendSentiment(data.delta, directionSentiment); const colorClass = sentimentColorClass(direction); - const isUp = data.delta > 0; const pct = Math.round(Math.abs(data.deltaPct) * 100); + + // A perfectly stable metric (current mean == prior mean, or a rounded + // delta of 0 %) reads neutral — a flat dash and "no change", never a + // down-arrow with "−0%". The direction arrow is decoupled from the raw + // sign so a zero delta never paints a misleading decrease. + if (data.delta === 0 || pct === 0) { + return ( + + + ); + } + + const isUp = data.delta > 0; const sign = isUp ? "+" : "−"; return ( diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 256b79573..15d157dc0 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { MoodHeatmap } from "@/components/charts/mood-heatmap"; import { @@ -140,9 +141,20 @@ export function MoodInsightsSections() { return (
{(hasInTargetTile || hasStabilityTile) && ( -
- - + // Two-up only when BOTH tiles render; a lone tile spans full width so + // it never leaves a half-width orphan with dead space beside it. +
+ {hasInTargetTile && ( + + )} + {hasStabilityTile && ( + + )}
)} diff --git a/src/components/insights/time-range-pills.tsx b/src/components/insights/time-range-pills.tsx index f02e69d92..32630c9ff 100644 --- a/src/components/insights/time-range-pills.tsx +++ b/src/components/insights/time-range-pills.tsx @@ -1,5 +1,7 @@ "use client"; +import { useRef, type KeyboardEvent } from "react"; + import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { @@ -15,6 +17,11 @@ import { * pulling a new Radix dependency — the project's tab strip uses the same * custom-pill approach. The selected range drives the period-over-period * delta caption and (where wired) the chart window. + * + * Follows the WAI-ARIA APG radiogroup pattern: the group is a single tab + * stop (only the checked radio is `tabIndex={0}`, the rest `-1`) and the + * arrow keys / Home / End move selection with a roving focus, so a keyboard + * user does not tab through all four and the arrow keys are not dead. */ export function TimeRangePills({ value, @@ -26,25 +33,63 @@ export function TimeRangePills({ className?: string; }) { const { t } = useTranslations(); + const radiosRef = useRef>([]); + + const selectAt = (index: number) => { + const next = ANALYTICS_RANGES[index]; + onChange(next); + // Roving focus follows selection so the newly checked radio is focused. + radiosRef.current[index]?.focus(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + const current = ANALYTICS_RANGES.indexOf(value); + const last = ANALYTICS_RANGES.length - 1; + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + event.preventDefault(); + selectAt(current >= last ? 0 : current + 1); + break; + case "ArrowLeft": + case "ArrowUp": + event.preventDefault(); + selectAt(current <= 0 ? last : current - 1); + break; + case "Home": + event.preventDefault(); + selectAt(0); + break; + case "End": + event.preventDefault(); + selectAt(last); + break; + } + }; return (
- {ANALYTICS_RANGES.map((range) => { + {ANALYTICS_RANGES.map((range, index) => { const selected = range === value; return (