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,137 @@
import { describe, expect, it } from "vitest";
import {
buildTourStops,
currentStop,
initTourState,
isTourFinished,
nextStep,
prevStep,
skipTour,
stepCounter,
} from "../tour-state";
describe("tour-state", () => {
describe("buildTourStops()", () => {
it("includes the achievements stop by default (5 stops)", () => {
const stops = buildTourStops();
expect(stops.map((s) => s.id)).toEqual([
"tile-strip",
"quick-add",
"insights",
"integrations",
"achievements",
]);
});
it("omits the achievements stop when the flag is off (4 stops)", () => {
const stops = buildTourStops({ includeAchievements: false });
expect(stops.map((s) => s.id)).toEqual([
"tile-strip",
"quick-add",
"insights",
"integrations",
]);
});
it("every stop carries a non-empty i18n title and body key", () => {
const stops = buildTourStops();
for (const s of stops) {
expect(s.titleKey).toMatch(/^onboarding\.tour\.steps\./);
expect(s.bodyKey).toMatch(/^onboarding\.tour\.steps\./);
expect(s.titleKey).not.toBe(s.bodyKey);
}
});
it("every stop declares a stable targetId for the data-tour-id contract", () => {
const stops = buildTourStops();
for (const s of stops) {
// `null` is allowed (centred-screen tooltip without a
// spotlight cutout) but we currently use real targets for
// every stop — a regression here would mean the spotlight
// silently falls back to centre-screen.
expect(typeof s.targetId === "string" && s.targetId.length > 0).toBe(
true,
);
}
});
});
describe("navigation", () => {
it("starts at index 0 with outcome null", () => {
const state = initTourState(buildTourStops());
expect(state.index).toBe(0);
expect(state.outcome).toBeNull();
expect(isTourFinished(state)).toBe(false);
expect(currentStop(state)?.id).toBe("tile-strip");
});
it("nextStep advances through the list", () => {
let state = initTourState(buildTourStops());
state = nextStep(state);
expect(currentStop(state)?.id).toBe("quick-add");
state = nextStep(state);
expect(currentStop(state)?.id).toBe("insights");
});
it("nextStep on the last step finishes the tour with outcome=completed", () => {
const stops = buildTourStops();
let state = initTourState(stops);
// step through every stop
for (let i = 0; i < stops.length; i++) {
state = nextStep(state);
}
expect(state.outcome).toBe("completed");
expect(isTourFinished(state)).toBe(true);
expect(currentStop(state)).toBeNull();
});
it("prevStep is pinned at index 0 (LeftArrow on first step is a no-op)", () => {
const state = initTourState(buildTourStops());
const back = prevStep(state);
expect(back.index).toBe(0);
expect(back.outcome).toBeNull();
});
it("prevStep walks backward when not at the start", () => {
let state = initTourState(buildTourStops());
state = nextStep(state);
state = nextStep(state);
expect(state.index).toBe(2);
const back = prevStep(state);
expect(back.index).toBe(1);
});
it("skipTour from any step finishes with outcome=skipped", () => {
let state = initTourState(buildTourStops());
state = nextStep(state); // on step 2 of 5
const skipped = skipTour(state);
expect(skipped.outcome).toBe("skipped");
expect(isTourFinished(skipped)).toBe(true);
expect(currentStop(skipped)).toBeNull();
});
it("nextStep / prevStep / skipTour are no-ops once finished", () => {
let state = initTourState(buildTourStops());
state = skipTour(state);
const after = nextStep(prevStep(skipTour(state)));
expect(after).toEqual(state);
});
});
describe("stepCounter", () => {
it("reports 1-based current and the total step count", () => {
const stops = buildTourStops();
let state = initTourState(stops);
expect(stepCounter(state)).toEqual({ current: 1, total: stops.length });
state = nextStep(state);
expect(stepCounter(state)).toEqual({ current: 2, total: stops.length });
});
it("clamps `current` to total when the tour is finished", () => {
let state = initTourState(buildTourStops({ includeAchievements: false }));
state = skipTour(state);
expect(stepCounter(state)).toEqual({ current: 4, total: 4 });
});
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* v1.4.15 Phase B5 — onboarding tour state model.
*
* Pure data + state-machine helpers for the spotlight tour overlaid on
* the dashboard for first-time users. Kept DOM-free so vitest (Node
* environment, no jsdom) can exercise the navigation logic directly,
* matching the project test-style elsewhere.
*
* The UI layer (`src/components/onboarding/tour.tsx`) builds the
* step list once on mount and then drives navigation through these
* helpers.
*
* Tour stops are anchored to UI targets via `data-tour-id` attributes
* (NOT class-name selectors — class names change too often during
* Tailwind tweaks; `data-tour-id` is a stable contract). Every stop
* declares its `targetId`; the spotlight component looks up the live
* element via `document.querySelector('[data-tour-id="<id>"]')`.
*
* If a target element is missing at runtime (e.g. user is on a
* resized layout that hides it), the spotlight falls back to a
* centred-screen tooltip so the tour never blocks indefinitely.
*/
/** Stable IDs for every stop in the tour. */
export type TourStopId =
| "tile-strip"
| "quick-add"
| "insights"
| "integrations"
| "achievements";
/**
* One step in the tour. The `targetId` matches a `data-tour-id` on a
* live element somewhere in the app; the title/body i18n keys live
* under `onboarding.tour.steps.<id>.*`.
*/
export interface TourStop {
id: TourStopId;
/**
* The `data-tour-id` value of the element to anchor the spotlight
* to. May be `null` if the stop is purely informational and should
* render in a centred-screen tooltip without a spotlight cutout.
*/
targetId: string | null;
/**
* Preferred placement of the tooltip relative to the target. The UI
* layer is allowed to flip this if the target is too close to a
* viewport edge.
*/
placement: "top" | "bottom" | "left" | "right" | "center";
/** i18n key for the step title. */
titleKey: string;
/** i18n key for the step body. */
bodyKey: string;
}
/**
* Build the canonical tour-step list. Caller may inject a feature
* flag — e.g. `includeAchievements: false` if B4 hasn't shipped yet
* — and we omit that stop without renumbering.
*/
export function buildTourStops(opts?: {
includeAchievements?: boolean;
}): TourStop[] {
const includeAchievements = opts?.includeAchievements ?? true;
const stops: TourStop[] = [
{
id: "tile-strip",
targetId: "dashboard-tile-strip",
placement: "bottom",
titleKey: "onboarding.tour.steps.tileStrip.title",
bodyKey: "onboarding.tour.steps.tileStrip.body",
},
{
id: "quick-add",
targetId: "dashboard-quick-add",
placement: "bottom",
titleKey: "onboarding.tour.steps.quickAdd.title",
bodyKey: "onboarding.tour.steps.quickAdd.body",
},
{
id: "insights",
targetId: "nav-insights",
placement: "right",
titleKey: "onboarding.tour.steps.insights.title",
bodyKey: "onboarding.tour.steps.insights.body",
},
{
id: "integrations",
// The integrations link sits behind the Settings sub-menu on
// some viewports — we anchor to the Settings nav item instead
// so the spotlight is always visible without forcing a hover.
targetId: "nav-settings",
placement: "right",
titleKey: "onboarding.tour.steps.integrations.title",
bodyKey: "onboarding.tour.steps.integrations.body",
},
];
if (includeAchievements) {
stops.push({
id: "achievements",
targetId: "nav-achievements",
placement: "right",
titleKey: "onboarding.tour.steps.achievements.title",
bodyKey: "onboarding.tour.steps.achievements.body",
});
}
return stops;
}
/**
* Tour navigation state. Pure value, immutable per transition — every
* helper returns a fresh state instead of mutating in place.
*/
export interface TourState {
/** Index into the steps array. May equal `steps.length` to indicate the tour is complete. */
index: number;
/** Cached step list — handy for the renderer. */
steps: TourStop[];
/**
* `null` while the tour is still running, `"completed"` once the
* user reached the end and clicked Done, `"skipped"` if they hit
* Skip on any step. Both terminal states cause the same DB write
* (`onboardingTourCompleted = true`); we keep them distinct purely
* so analytics can tell them apart in audit logs.
*/
outcome: "completed" | "skipped" | null;
}
/** Initial state at the first step. */
export function initTourState(steps: TourStop[]): TourState {
return { index: 0, steps, outcome: null };
}
/**
* Advance to the next step. If we're already on the last step, mark
* the tour `completed`. Calling on a terminal state is a no-op so
* the renderer can fire-and-forget on rapid double-clicks.
*/
export function nextStep(state: TourState): TourState {
if (state.outcome !== null) return state;
const lastIndex = state.steps.length - 1;
if (state.index >= lastIndex) {
return { ...state, index: state.steps.length, outcome: "completed" };
}
return { ...state, index: state.index + 1 };
}
/**
* Step backwards. Pinned at index 0 — the first step has no Back
* button visually; this helper just no-ops to keep the keyboard
* shortcut (LeftArrow) safe.
*/
export function prevStep(state: TourState): TourState {
if (state.outcome !== null) return state;
if (state.index <= 0) return state;
return { ...state, index: state.index - 1 };
}
/** User explicitly dismissed the tour from any step. */
export function skipTour(state: TourState): TourState {
if (state.outcome !== null) return state;
return { ...state, index: state.steps.length, outcome: "skipped" };
}
/** Whether the tour has reached a terminal state. */
export function isTourFinished(state: TourState): boolean {
return state.outcome !== null;
}
/** The current stop, or null if the tour is finished. */
export function currentStop(state: TourState): TourStop | null {
if (state.outcome !== null) return null;
return state.steps[state.index] ?? null;
}
/** 1-based step counter for "{n}/{total}" displays. */
export function stepCounter(state: TourState): {
current: number;
total: number;
} {
return {
current: Math.min(state.index + 1, state.steps.length),
total: state.steps.length,
};
}