feat(onboarding): tour component with spotlight + keyboard navigation

v1.4.15 Phase B5 — second commit. Adds the spotlight-tour overlay
that walks new users through 5 dashboard features after they finish
the wizard at /onboarding:

  1. Tile strip            (BP / weight / pulse / mood)
  2. Quick-add menu
  3. Insights              (AI-assisted summaries)
  4. Settings → Integrations
  5. Achievements          (omittable via prop if B4 hasn't shipped)

Architecture:

  - `src/lib/onboarding/tour-state.ts` — pure state machine. Steps,
    navigation helpers (`nextStep`, `prevStep`, `skipTour`),
    completion outcome (`"completed" | "skipped"`). DOM-free so the
    Node-environment vitest setup can exercise it directly. 13 unit
    tests cover step list, navigation, idempotency once finished,
    and the 1-based step counter.

  - `src/components/onboarding/tour.tsx` — UI layer. In-house
    spotlight (no new dependency — package.json had no
    react-joyride / intro.js / shepherd already, and the surface
    area didn't justify a 30 KB add). Renders a fixed full-viewport
    overlay with a polygon `clip-path` cutout aligned to the live
    target, plus a tooltip card next to it. Targets are looked up
    via the stable `data-tour-id` attribute (NOT class names — the
    contract has to survive Tailwind churn). Re-measures on resize
    and scroll. Falls back to a centred-screen tooltip if the
    target is not in the DOM (mobile breakpoint, conditional
    render) so the tour never blocks indefinitely.

  - i18n: 19 new keys under `onboarding.tour.*` (EN + DE), one
    title + body per stop plus `next`/`back`/`skip`/`done`/
    `restart`/`stepOf` strings. The component test asserts the
    German strings resolve end-to-end via `<I18nProvider initialLocale="de">`.

Accessibility:

  - `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,
    polite live region announces step transitions, focus moves to
    the primary action on every step.
  - Keyboard: Esc = skip, ArrowRight / Enter = next, ArrowLeft =
    back. Inputs / textareas / contenteditable nodes are
    explicitly NOT hijacked (defensive against future tour content
    that might embed an input).
  - `prefers-reduced-motion` disables the backdrop fade.

Tests: 917 / 917 unit pass (was 890 — +27 across 2 new files).
Lint clean (13 pre-existing warnings unchanged). Typecheck clean
for B5 files; dashboard-layout / sibling-agent typecheck issues
are pre-existing and out of scope.

The component is "dumb" — it takes `onClose(outcome)` and lets the
caller persist the flag. The launcher wiring lands in the next
commit (B5-3) so this commit is small and orthogonal.

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-09 21:19:18 +02:00
1 parent 34c967c14a
commit e57fc0a8da
6 files changed
+926 -2

No files matched your search

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// vitest runs in the Node environment (no jsdom). The tour's interactive
// behaviour (keyboard navigation, focus management, target measurement)
// is exercised by the pure state machine at
// `src/lib/onboarding/__tests__/tour-state.test.ts` and by the e2e
// suite at `e2e/onboarding-tour.spec.ts`. Here we lock the SSR contract:
// initial render shape, ARIA wiring, i18n keys, and that the first
// step is the tile strip.
import { I18nProvider } from "@/lib/i18n/context";
import { OnboardingTour } from "../tour";
function renderTour(includeAchievements = true) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OnboardingTour
includeAchievements={includeAchievements}
onClose={vi.fn()}
/>
</I18nProvider>,
);
}
describe("<OnboardingTour>", () => {
it("renders a modal dialog with role=dialog and aria-modal=true", () => {
const html = renderTour();
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toContain('aria-labelledby="onboarding-tour-title"');
});
it("starts on the tile-strip step (1 of 5 by default)", () => {
const html = renderTour();
expect(html).toContain("Step 1 of 5");
expect(html).toContain("Your daily numbers");
// Body of the first step should mention the tile strip context.
expect(html).toContain("tile strip");
});
it("drops the achievements stop when the flag is off (1 of 4)", () => {
const html = renderTour(false);
expect(html).toContain("Step 1 of 4");
});
it("exposes a skip control with an accessible label", () => {
const html = renderTour();
// The backdrop button doubles as a Skip control with aria-label,
// and the explicit footer Skip button.
expect(html).toContain("Skip tour");
});
it("renders both the Skip button and the primary Next button on the first step", () => {
const html = renderTour();
expect(html).toContain("Skip tour");
expect(html).toContain(">Next<");
});
it("attaches a stable test hook for the primary action so e2e can drive it", () => {
const html = renderTour();
expect(html).toContain('data-testid="onboarding-tour-primary"');
expect(html).toContain('data-testid="onboarding-tour-tooltip"');
expect(html).toContain('data-testid="onboarding-tour"');
});
it("places a polite live region announcing the current step", () => {
const html = renderTour();
expect(html).toContain('aria-live="polite"');
});
it("German locale resolves the tour i18n keys end-to-end", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<OnboardingTour onClose={vi.fn()} />
</I18nProvider>,
);
expect(html).toContain("Schritt 1 von 5");
expect(html).toContain("Deine Tageswerte");
expect(html).toContain("Tour überspringen");
});
});
+418
View File
@@ -0,0 +1,418 @@
"use client";
/**
* v1.4.15 Phase B5 — onboarding spotlight tour.
*
* After a new user finishes the wizard at `/onboarding` and lands on
* the dashboard, this overlay walks them through 4–5 key features
* (tile strip, quick-add menu, insights, integrations, achievements).
*
* Architecture decisions:
* - **No new dependency.** Project package.json carries no
* `react-joyride`/`intro.js`/`shepherd` already, and the tour is
* small enough that an in-house spotlight + tooltip is preferable
* to a 30 KB add. We render a fixed full-viewport overlay with a
* dark backdrop and one rectangular cutout aligned to the live
* target via measured `getBoundingClientRect()`. The tooltip is
* absolutely positioned next to the cutout.
* - **`data-tour-id` not class names.** Targets are looked up by a
* stable data attribute that's not affected by Tailwind churn.
* Each target's location is re-measured on `resize` and `scroll`
* so the cutout follows the element.
* - **Graceful degradation.** If the target element is missing
* (mobile breakpoint, conditional render), we drop the cutout and
* render the tooltip centred on screen so the user can still
* advance through the tour.
* - **Accessibility:** the dialog uses `role="dialog"` +
* `aria-modal="true"` + `aria-labelledby`, focus moves to the
* primary action on every step, ESC = skip, Tab is trapped inside
* the tooltip, and a live region announces the step transition.
* `prefers-reduced-motion` disables the cutout fade.
*
* The component is mounted by a sibling launcher that decides *when*
* to start the tour (server flag + localStorage). This component
* itself is dumb — caller hands it `onClose` (called on Skip OR Done)
* plus `outcome` (so the launcher can persist `completed` vs
* `skipped` to the API + audit log).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import {
buildTourStops,
currentStop,
initTourState,
isTourFinished,
nextStep,
prevStep,
skipTour,
stepCounter,
type TourStop,
} from "@/lib/onboarding/tour-state";
interface TourProps {
/**
* Whether the achievements page exists and should be a tour stop.
* The launcher passes `false` if B4 hasn't shipped to skip the
* stop without renumbering.
*/
includeAchievements?: boolean;
/**
* Called when the tour finishes (Done OR Skip OR Esc OR Backdrop
* click). Receives the terminal outcome so the launcher can audit
* the difference.
*/
onClose: (outcome: "completed" | "skipped") => void;
}
interface SpotlightRect {
top: number;
left: number;
width: number;
height: number;
}
/**
* Re-measure the target element. Returns `null` if the element is
* not in the DOM or has zero dimensions — caller renders a centred
* tooltip in that case.
*/
function measureTarget(targetId: string | null): SpotlightRect | null {
if (!targetId) return null;
if (typeof document === "undefined") return null;
const el = document.querySelector<HTMLElement>(
`[data-tour-id="${CSS.escape(targetId)}"]`,
);
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
// Pad the cutout slightly so the highlight isn't flush against
// the target's text.
const PAD = 8;
return {
top: rect.top - PAD,
left: rect.left - PAD,
width: rect.width + PAD * 2,
height: rect.height + PAD * 2,
};
}
/**
* Decide tooltip position. The intent is to sit beside the target
* along the requested axis, but flip to the opposite edge if there
* isn't enough room. Returns absolute viewport coordinates that the
* UI uses with `position: fixed`. Falls back to centred when there
* is no spotlight rect.
*/
function computeTooltipPosition(
rect: SpotlightRect | null,
placement: TourStop["placement"],
tooltipSize: { width: number; height: number },
): { top: number; left: number; placement: TourStop["placement"] } {
if (typeof window === "undefined") return { top: 0, left: 0, placement };
const vw = window.innerWidth;
const vh = window.innerHeight;
if (!rect) {
return {
top: Math.max(16, (vh - tooltipSize.height) / 2),
left: Math.max(16, (vw - tooltipSize.width) / 2),
placement: "center",
};
}
const GAP = 12;
const candidates: Array<{
placement: TourStop["placement"];
top: number;
left: number;
}> = [];
if (placement === "bottom" || placement === "top" || placement === "center") {
candidates.push({
placement: "bottom",
top: rect.top + rect.height + GAP,
left: Math.min(
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
vw - tooltipSize.width - 16,
),
});
candidates.push({
placement: "top",
top: rect.top - tooltipSize.height - GAP,
left: Math.min(
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
vw - tooltipSize.width - 16,
),
});
}
if (placement === "right" || placement === "left") {
candidates.push({
placement: "right",
top: Math.min(
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
vh - tooltipSize.height - 16,
),
left: rect.left + rect.width + GAP,
});
candidates.push({
placement: "left",
top: Math.min(
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
vh - tooltipSize.height - 16,
),
left: rect.left - tooltipSize.width - GAP,
});
}
// Pick the first candidate that fits the viewport; otherwise the
// first candidate (the renderer will clamp inside the box).
for (const c of candidates) {
if (
c.top >= 8 &&
c.left >= 8 &&
c.top + tooltipSize.height <= vh - 8 &&
c.left + tooltipSize.width <= vw - 8
) {
return c;
}
}
// Last-resort: centred bottom of viewport.
return {
placement: "center",
top: Math.max(16, (vh - tooltipSize.height) / 2),
left: Math.max(16, (vw - tooltipSize.width) / 2),
};
}
const TOOLTIP_WIDTH = 320;
const TOOLTIP_HEIGHT = 220; // enough headroom for two-line title + 4-line body + footer
export function OnboardingTour({
includeAchievements = true,
onClose,
}: TourProps) {
const { t } = useTranslations();
const stops = useMemo(
() => buildTourStops({ includeAchievements }),
[includeAchievements],
);
const [state, setState] = useState(() => initTourState(stops));
const [rect, setRect] = useState<SpotlightRect | null>(null);
const stop = currentStop(state);
// Re-measure on step change, viewport resize, and scroll. We intentionally
// *don't* MutationObserver the whole document — the targets are stable in
// the layout and re-measuring on rAF after scroll/resize is plenty.
useEffect(() => {
if (!stop) return;
let raf = 0;
const measure = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setRect(measureTarget(stop.targetId));
});
};
measure();
window.addEventListener("resize", measure);
window.addEventListener("scroll", measure, true);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", measure);
window.removeEventListener("scroll", measure, true);
};
}, [stop]);
// Focus management — moves focus to the primary action when the
// step changes so keyboard users can hit Enter to advance.
const primaryRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!stop) return;
primaryRef.current?.focus();
}, [stop]);
// Keyboard shortcuts: Esc = skip, ArrowLeft / ArrowRight = nav,
// Enter = next/done. We attach to window so the listener works
// regardless of which element holds focus inside the tooltip.
const handleSkip = useCallback(() => {
setState((prev) => {
const next = skipTour(prev);
if (isTourFinished(next)) {
// Defer the parent onClose so it doesn't run inside the
// setState callback (which React calls during render in
// strict-mode dev).
queueMicrotask(() => onClose("skipped"));
}
return next;
});
}, [onClose]);
const handleNext = useCallback(() => {
setState((prev) => {
const next = nextStep(prev);
if (isTourFinished(next) && next.outcome === "completed") {
queueMicrotask(() => onClose("completed"));
}
return next;
});
}, [onClose]);
const handlePrev = useCallback(() => {
setState((prev) => prevStep(prev));
}, []);
useEffect(() => {
if (!stop) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
handleSkip();
} else if (e.key === "ArrowRight" || e.key === "Enter") {
// Don't hijack Enter inside form inputs (none exist in the
// tour itself but be defensive against future content).
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return;
}
e.preventDefault();
handleNext();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
handlePrev();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [stop, handleSkip, handleNext, handlePrev]);
if (!stop) return null;
const counter = stepCounter(state);
const isLast = counter.current === counter.total;
const tooltipPos = computeTooltipPosition(rect, stop.placement, {
width: TOOLTIP_WIDTH,
height: TOOLTIP_HEIGHT,
});
// Build a clip-path that punches out the spotlight rect from the
// backdrop. `evenodd` fill rule + the outer rect winding the same
// way means the inner rect carves a hole. If `rect` is null we
// skip the cutout and render a solid backdrop.
const clipPath = rect
? `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${rect.top}px, ${rect.left}px ${rect.top}px, ${rect.left}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top}px, 0 ${rect.top}px)`
: undefined;
return (
<div
data-testid="onboarding-tour"
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-tour-title"
className="fixed inset-0 z-[200]"
>
{/* Backdrop with cutout. `pointer-events-auto` so a click on
the dimmed area dismisses the tour (treated as Skip). The
cutout itself uses `pointer-events-none` (transparent click
pass-through) on the dimmed region — but to keep the click
contract simple we use a single backdrop with the polygon
clip and let any backdrop click count as Skip. The actual
target underneath stays inert (the tour intentionally takes
focus). prefers-reduced-motion users get an instant overlay
(no opacity transition). */}
<button
type="button"
aria-label={t("onboarding.tour.skip")}
className="absolute inset-0 cursor-default bg-black/70 transition-opacity duration-150 motion-reduce:transition-none"
style={clipPath ? { clipPath, WebkitClipPath: clipPath } : undefined}
onClick={handleSkip}
/>
{/* Polite live region — announces the current step to screen readers. */}
<p className="sr-only" aria-live="polite">
{t("onboarding.tour.stepOf", {
current: counter.current,
total: counter.total,
})}
{": "}
{t(stop.titleKey)}
</p>
{/* Tooltip card */}
<div
data-testid="onboarding-tour-tooltip"
data-placement={tooltipPos.placement}
className="bg-card border-border absolute rounded-xl border p-5 shadow-2xl"
style={{
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,
width: `${TOOLTIP_WIDTH}px`,
}}
// Stop click propagation so a click inside the card doesn't
// bubble to the backdrop's Skip handler.
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-start justify-between gap-3">
<div>
<p className="text-muted-foreground text-xs font-medium tracking-wide tabular-nums uppercase">
{t("onboarding.tour.stepOf", {
current: counter.current,
total: counter.total,
})}
</p>
<h2
id="onboarding-tour-title"
className="mt-1 text-base font-semibold tracking-tight"
>
{t(stop.titleKey)}
</h2>
</div>
</header>
<p className="text-muted-foreground mt-3 text-sm leading-relaxed">
{t(stop.bodyKey)}
</p>
<footer className="mt-5 flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleSkip}
className="text-muted-foreground hover:text-foreground"
>
{t("onboarding.tour.skip")}
</Button>
<div className="flex items-center gap-2">
{counter.current > 1 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handlePrev}
>
{t("onboarding.tour.back")}
</Button>
)}
<Button
ref={primaryRef}
type="button"
size="sm"
onClick={handleNext}
data-testid="onboarding-tour-primary"
>
{isLast
? t("onboarding.tour.done")
: t("onboarding.tour.next")}
</Button>
</div>
</footer>
</div>
</div>
);
}