fix(onboarding): really stop spotlight tour from blocking the quick-add button

The first attempt at the F2 fix (f9b8f3bd) split the spotlight tour
backdrop into four rectangular dim panels around the spotlight rect
and opted each one back into pointer-events: auto with its own
onClick={handleSkip}. The intent was to keep the spotlighted target
clickable while preserving the "tap backdrop to dismiss" affordance.

The flaw: tour-step 1 on the dashboard spotlights the tile-strip,
which sits in the middle of the page. The header "Add" button sits
ABOVE the spotlight. The TOP dim strip (rendered from y=0 down to
where the spotlight begins) therefore covered the entire header
area, so the very target the F2 audit named — the header Add
dropdown — was still being eaten, just by a smaller rectangle.
The four-panel split fixed exactly ONE coordinate (inside the
spotlight rect) and broke every coordinate outside it that a real
user might want to click during the tour: header buttons, the
user-menu avatar, sidebar links, the locale switcher.

This commit ships the contract that Joyride / Shepherd / Intro.js
have all standardised on: the dim is purely visual. The whole
tour layer is pointer-events: none; only the tooltip card opts
back into hit-testing. The four-panel split survives because it
still serves the visual purpose of NOT dimming the spotlighted
target, but each panel is now a <div aria-hidden="true"> with
pointer-events: none — no onClick, no aria-label, no interactive
semantics. The center-placement fallback follows the same shape.
The tooltip's onClick={(e) => e.stopPropagation()} guard came off
because nothing bubbles to the dim any more. Tour state (Skip,
Back, Next) lives entirely on the tooltip's footer buttons,
matching the affordance users expect and keyboard-accessible via
the existing Esc shortcut.

The e2e regression spec at e2e/onboarding-tour-passthrough.spec.ts
gained a third case and tightened the first one. Case 1 now runs
document.elementsFromPoint at the quick-add button's centre, walks
the stack, and asserts that NO element belonging to the tour
overlay has computed pointer-events !== "none". This pinpoints the
regression instead of observing the symptom. Case 2 swapped from
"click a dim strip skips" (an artefact of the failed intermediate
fix) to "the explicit Skip button in the tooltip dismisses the
tour". Case 3 reads computed pointer-events from every
[data-testid="onboarding-tour-dim"] and asserts each is "none" —
the load-bearing invariant the F2 regression violated.

Quality gates: typecheck clean, eslint clean on the touched files,
all 43 onboarding unit tests pass, all 6 F2 e2e cases pass locally
(3 cases x chromium-desktop + chromium-mobile).
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 18:42:02 +02:00
1 parent 6ccb7b3c65
commit ab1700a843
4 files changed
+288 -73

No files matched your search

+117 -32
View File
@@ -6,23 +6,36 @@ 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 original v1.4.15 tour rendered 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 captured every pixel,
* 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 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.
* The first attempt at a fix split the dim into four panels around the
* spotlight rect and gave each panel an `onClick={handleSkip}` with
* `pointer-events: auto`. That made the spotlight area click-through
* but kept blocking every other interactive element on the page: the
* header sits ABOVE the tile-strip spotlight, so the TOP dim strip
* still intercepted the click on the Hinzufügen button — exactly the
* target the audit named. New users were still locked out.
*
* The shipped fix makes the dim purely visual. The whole tour layer is
* `pointer-events: none`; only the tooltip card opts back into hit
* testing. Tour state (Skip / Back / Next) lives entirely on the
* tooltip's footer buttons — matching the spotlight-tour conventions
* used by Joyride, Shepherd, and Intro.js. The page underneath stays
* fully usable during the tour.
*
* This spec encodes both halves of that contract:
* 1. Clicking the header Hinzufügen button mid-tour opens its
* dropdown menu (no element on top of it is `pointer-events:
* auto`).
* 2. Clicking the tooltip's explicit Skip button dismisses the tour.
* 3. Every dim panel computes `pointer-events: none`.
*/
test.describe("v1.4.33 F2 — onboarding tour passes clicks through spotlight", () => {
test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
@@ -121,27 +134,70 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through spotlight",
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.
// The header "Hinzufügen" button lives ABOVE the spotlight on
// step 1 (which spotlights the tile strip further down the page),
// so a naive "dim everything outside the spotlight rect" overlay
// covers it. The fix makes the entire tour layer pointer-events:
// none — every dim panel and the spotlight ring are visual only.
const quickAddButton = page.locator('[data-tour-id="dashboard-quick-add"]');
await expect(quickAddButton).toBeVisible();
// Forensic guard — at the quick-add button's centre, every element
// returned by `elementsFromPoint` that belongs to the tour overlay
// MUST be CSS-`pointer-events: none`. If any tour element above the
// button has `pointer-events: auto`, the button's click will be
// intercepted and the dropdown will not open. This assertion is the
// tight version of the regression — it pinpoints WHICH element on
// the stack is blocking, instead of just observing the symptom
// ("button click did not toggle aria-expanded").
const tourBlockers = await page.evaluate(() => {
const btn = document.querySelector<HTMLElement>(
'[data-tour-id="dashboard-quick-add"]',
);
if (!btn) return ["no-button"];
const rect = btn.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const stack = document.elementsFromPoint(x, y);
const offenders: string[] = [];
for (const el of stack) {
const tour = (el as HTMLElement).closest(
'[data-testid="onboarding-tour"]',
);
if (!tour) continue;
const style = window.getComputedStyle(el as HTMLElement);
if (style.pointerEvents !== "none") {
offenders.push(
`${(el as HTMLElement).tagName}#${(el as HTMLElement).id || "-"}.${
(el as HTMLElement).className || "-"
} pe=${style.pointerEvents}`,
);
}
}
return offenders;
});
expect(
tourBlockers,
"tour overlay must not place a pointer-events:auto element above the Hinzufügen button",
).toEqual([]);
// Now the click — Radix's DropdownMenuTrigger flips aria-expanded
// to "true" and renders the menu items into a portal as
// role="menuitem". With the old single-backdrop overlay this click
// hit the dim layer and called `handleSkip`; with the four-panel
// overlay the TOP strip still intercepted it; with the shipped fix
// the dim is pointer-events:none and the click reaches the button.
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.
// not anything in the tour layer.
await expect(tourRoot).toBeVisible();
});
test("clicking a dim strip outside the spotlight still skips the tour", async ({
test("explicit Skip button in the tooltip dismisses the tour", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -150,14 +206,43 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through spotlight",
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();
// The tooltip's footer Skip button is the canonical skip surface.
// The dim panels are purely visual (pointer-events: none) so the
// page underneath stays usable; users skip by clicking the explicit
// "Skip tour" control, which is also keyboard-accessible (Esc) and
// matches industry-standard spotlight-tour patterns.
const tooltip = page.locator('[data-testid="onboarding-tour-tooltip"]');
await expect(tooltip).toBeVisible();
await tooltip.getByRole("button", { name: /skip tour/i }).click();
// Tour unmounts. Hinzufügen stays usable.
// Tour unmounts.
await expect(tourRoot).toBeHidden({ timeout: 5_000 });
});
test("dim panels render as non-interactive visual layers", 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 });
// Every dim panel must have computed `pointer-events: none`.
// This is the load-bearing invariant — the F2 regression was that
// these panels had pointer-events: auto and ate clicks aimed at
// real interactive elements behind them.
const dimPointerEvents = await page.evaluate(() => {
const panels = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid="onboarding-tour-dim"]',
),
);
return panels.map((el) => window.getComputedStyle(el).pointerEvents);
});
expect(dimPointerEvents.length).toBeGreaterThan(0);
for (const pe of dimPointerEvents) {
expect(pe).toBe("none");
}
});
});