fix(onboarding): stop spotlight tour from intercepting dashboard clicks

The v1.4.15 spotlight tour rendered the dim backdrop as a single full-
viewport <button> with a clip-path punching a visual hole around the
target. clip-path only changes paint — the button's hit-box stayed
over the entire viewport, so every click on the underlying page
(Hinzufügen dropdown, sidebar links, header avatar) hit the dim layer
and triggered Skip without forwarding the click to the real target.
New users landing on the dashboard with onboarding_tour_completed=false
were effectively locked out of the most common action.

Replace the single backdrop with up to four rectangular dim panels
(top / bottom / left / right strips around the spotlight rect) and
set pointer-events: none on the tour root. The spotlight area is now
genuinely click-through — the spotlighted target receives clicks
normally — and the dim panels keep the "tap backdrop to dismiss"
affordance via their own onClick handlers. The center-placement
fallback (no spotlight target available) retains the legacy full-
cover dim behaviour. Tooltip opts back into click capture with
pointer-events: auto so its buttons stay reachable.

Adds an e2e regression guard
(e2e/onboarding-tour-passthrough.spec.ts) that mounts the tour by
overriding /api/auth/me with onboardingTourCompleted=false, then
asserts the header Hinzufügen dropdown opens on click while the tour
is up. Covers both branches: click on the spotlighted target
forwards to the underlying button, click on a dim strip skips. SSR
tour test stays green (8/8) — the role=dialog, aria-modal,
data-testid hooks, step counter, and i18n keys are unchanged.

Refs: .planning/round-v1433-audit-runtime.md F2
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:11:45 +02:00
1 parent 2d630994a2
commit f9b8f3bde2
2 files changed
+278 -26

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.33 F2 regression guard — the onboarding spotlight tour overlay
* MUST NOT intercept clicks on elements outside the spotlight area.
*
* Before the fix the tour rendered a single full-viewport `<button>` with
* a `clip-path` punching a visual hole around the spotlight target. The
* clip-path only changed paint — the button's hit-box still captured
* every click in the viewport, so a new user could not click the header
* "Hinzufügen" dropdown (or any other interactive element on the
* dashboard) without the dim layer eating the click first. The audit
* (`.planning/round-v1433-audit-runtime.md` F2) flagged this as a
* Critical block: new users were effectively locked out.
*
* The fix splits the dim backdrop into up to four rectangles around the
* spotlight (top / bottom / left / right strips) and sets
* `pointer-events: none` on the tour root so the spotlight area is
* genuinely click-through. This spec proves the contract: with the
* tour mid-step on the dashboard, clicking the header "Hinzufügen"
* button opens its dropdown menu instead of skipping the tour.
*/
test.describe("v1.4.33 F2 — onboarding tour passes clicks through spotlight", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Force the spotlight tour to mount on the dashboard. The
// storageState user has `onboarding_tour_completed = true` so we
// override the auth payload to flip the flag back to `false`. The
// analytics + widget routes return a minimal-but-valid payload so
// the dashboard paints before the tour launcher's ready gate fires.
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "user_e2e",
username: "e2e-tester",
email: "e2e@healthlog.test",
role: "ADMIN",
heightCm: 180,
dateOfBirth: "1985-06-15",
gender: "MALE",
timezone: "Europe/Berlin",
onboardingCompletedAt: "2025-01-01T00:00:00.000Z",
// Critical for this spec — flips the spotlight tour back on.
onboardingTourCompleted: false,
gravatarUrl: null,
glucoseUnit: "mg/dL",
},
error: null,
}),
}),
);
await page.route("**/api/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78,
avg7: 78,
avg30: 78,
slope30: { slope: 0, direction: "flat" },
count: 30,
},
},
bpInTargetPct: null,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.route("**/api/dashboard/widgets", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { tilesVisible: { weight: true, bp: true, pulse: true } },
error: null,
}),
}),
);
await page.route("**/api/medications", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [], error: null }),
}),
);
});
test("Hinzufügen dropdown opens with the tour mounted on the dashboard", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Confirm the tour is up — proves the regression's preconditions
// (without the tour the test would be moot).
const tourRoot = page.locator('[data-testid="onboarding-tour"]');
await expect(tourRoot).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-testid="onboarding-tour-tooltip"]'),
).toBeVisible();
// The header "Hinzufügen" button lives outside the spotlight on
// step 1 (which spotlights the tile strip). Clicking it must open
// its dropdown menu, NOT dismiss the tour. With the old single-
// backdrop overlay this click hit the dim layer and called
// `handleSkip` instead.
const quickAddButton = page.locator('[data-tour-id="dashboard-quick-add"]');
await expect(quickAddButton).toBeVisible();
await quickAddButton.click();
// DropdownMenuTrigger toggles `aria-expanded` — Radix sets it to
// "true" once the menu is open. The menu items themselves render
// in a Radix portal as `role="menuitem"`.
await expect(quickAddButton).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("menuitem").first()).toBeVisible();
// The tour stays mounted — the click went to the underlying button,
// not the dim layer.
await expect(tourRoot).toBeVisible();
});
test("clicking a dim strip outside the spotlight still skips the tour", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
const tourRoot = page.locator('[data-testid="onboarding-tour"]');
await expect(tourRoot).toBeVisible({ timeout: 10_000 });
// The dim panels are the strips around the spotlight. Clicking any
// of them must skip the tour (preserves the "tap backdrop to
// dismiss" affordance the v1.4.15 design shipped with).
const dimPanel = page.locator('[data-testid="onboarding-tour-dim"]').first();
await expect(dimPanel).toBeVisible();
await dimPanel.click();
// Tour unmounts. Hinzufügen stays usable.
await expect(tourRoot).toBeHidden({ timeout: 5_000 });
});
});
+115 -26
View File
@@ -338,13 +338,59 @@ export function OnboardingTour({
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;
// v1.4.33 F2 — onboarding overlay was a single full-viewport `<button>`
// with a `clip-path` punching a hole around the spotlight. The clip-path
// only changed PAINT — the button's hit-box still covered every pixel,
// so every click on the underlying page (Hinzufügen, sidebar links,
// header avatar) hit the dim layer and triggered Skip without forwarding
// the click to the real target. New users were effectively locked out.
//
// Fix: render the dim backdrop as up to four separate rectangles around
// the spotlight (top / bottom / left / right of the cutout) so the
// spotlight region itself is genuinely click-through. Each rectangle
// keeps the "click backdrop = Skip" affordance via its own onClick.
// When there is no spotlight rect we fall back to a single full-cover
// dim button (legacy center-placement contract — tooltip is centred and
// the underlying page is intentionally inert until the user advances).
const backdropClass =
"focus-visible:ring-primary cursor-default bg-black/70 transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none motion-reduce:transition-none";
const vw = typeof window !== "undefined" ? window.innerWidth : 0;
const vh = typeof window !== "undefined" ? window.innerHeight : 0;
const dimRects = rect
? [
// Top strip — full width above spotlight.
rect.top > 0
? { top: 0, left: 0, width: vw, height: rect.top }
: null,
// Bottom strip — full width below spotlight.
rect.top + rect.height < vh
? {
top: rect.top + rect.height,
left: 0,
width: vw,
height: vh - (rect.top + rect.height),
}
: null,
// Left strip — only the band beside the spotlight.
rect.left > 0
? {
top: rect.top,
left: 0,
width: rect.left,
height: rect.height,
}
: null,
// Right strip — only the band beside the spotlight.
rect.left + rect.width < vw
? {
top: rect.top,
left: rect.left + rect.width,
width: vw - (rect.left + rect.width),
height: rect.height,
}
: null,
].filter((r): r is { top: number; left: number; width: number; height: number } => r !== null)
: null;
return (
<div
@@ -352,24 +398,65 @@ export function OnboardingTour({
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-tour-title"
className="fixed inset-0 z-[200]"
// `pointer-events-none` on the root: the outer container does NOT
// capture clicks. Children that need clicks (dim strips, tooltip)
// opt back in via `pointer-events-auto`. This guarantees the
// spotlight area passes pointer events through to the underlying
// page — the exact contract F2 needs.
className="pointer-events-none 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="focus-visible:ring-primary absolute inset-0 cursor-default bg-black/70 transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none motion-reduce:transition-none"
style={clipPath ? { clipPath, WebkitClipPath: clipPath } : undefined}
onClick={handleSkip}
/>
{/* Spotlight outline — purely visual ring around the highlighted
target. `pointer-events-none` so a click on the ring still
reaches the underlying element. */}
{rect ? (
<div
aria-hidden="true"
data-testid="onboarding-tour-spotlight"
className="ring-primary/80 pointer-events-none absolute rounded-lg ring-2 ring-offset-2 ring-offset-transparent"
style={{
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
}}
/>
) : null}
{/* Backdrop dim panels. Each panel sits OUTSIDE the spotlight rect
so the spotlighted target is fully click-through. A click on any
dim panel skips the tour (existing "tap backdrop to dismiss"
contract). `pointer-events-auto` reactivates click capture for
these specific rects only. */}
{dimRects
? dimRects.map((r, idx) => (
<button
key={idx}
type="button"
aria-label={t("onboarding.tour.skip")}
data-testid="onboarding-tour-dim"
className={`pointer-events-auto absolute ${backdropClass}`}
style={{
top: `${r.top}px`,
left: `${r.left}px`,
width: `${r.width}px`,
height: `${r.height}px`,
}}
onClick={handleSkip}
/>
))
: (
// Center-placement fallback (no spotlight target available).
// The legacy contract is to dim the whole viewport and let any
// click count as Skip — the tooltip is the only interactive
// surface in this branch.
<button
type="button"
aria-label={t("onboarding.tour.skip")}
data-testid="onboarding-tour-dim"
className={`pointer-events-auto absolute inset-0 ${backdropClass}`}
onClick={handleSkip}
/>
)}
{/* Polite live region — announces the current step to screen readers. */}
<p className="sr-only" aria-live="polite">
@@ -381,12 +468,14 @@ export function OnboardingTour({
{t(stop.titleKey)}
</p>
{/* Tooltip card */}
{/* Tooltip card — `pointer-events-auto` to opt back into click
capture (the root is `pointer-events-none` so the spotlight
area passes clicks through to the underlying page). */}
<div
ref={tooltipRef}
data-testid="onboarding-tour-tooltip"
data-placement={tooltipPos.placement}
className="bg-card border-border absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
className="bg-card border-border pointer-events-auto absolute max-h-[80vh] overflow-y-auto rounded-xl border p-5 shadow-2xl"
style={{
top: `${tooltipPos.top}px`,
left: `${tooltipPos.left}px`,