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

@@ -46,8 +46,10 @@ describe("<OnboardingTour>", () => {
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.
// After v1.4.33 F2 the dim panels are purely visual
// (pointer-events: none) so skip lives entirely on the explicit
// tooltip-footer Skip button. The visible label still has to be
// there — both for sighted users and for screen readers.
expect(html).toContain("Skip tour");
});
+43 -39
View File
@@ -345,15 +345,24 @@ export function OnboardingTour({
// 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).
// Initial fix (f9b8f3bd) split the dim into four rectangles around the
// spotlight with `pointer-events: auto` + an `onClick={handleSkip}` on
// each. That made the spotlight region 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 ate the click on
// the Hinzufügen dropdown — the very target the F2 audit named).
//
// The right contract: the dim is purely VISUAL. The whole tour layer is
// `pointer-events: none`, and only the tooltip card opts back into hit
// testing. Skip lives where users expect it — the explicit "Skip tour"
// button in the tooltip footer. The page underneath stays fully usable,
// matching the spotlight-tour conventions used by Joyride, Shepherd,
// and Intro.js. The four-panel split survives because it still serves
// the visual purpose of NOT dimming the spotlighted target, but the
// panels are now non-interactive `<div>`s without their own click
// handlers.
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";
"pointer-events-none absolute bg-black/70 transition-opacity duration-150 motion-reduce:transition-none";
const vw = typeof window !== "undefined" ? window.innerWidth : 0;
const vh = typeof window !== "undefined" ? window.innerHeight : 0;
const dimRects = rect
@@ -399,15 +408,15 @@ export function OnboardingTour({
aria-modal="true"
aria-labelledby="onboarding-tour-title"
// `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.
// capture clicks. Only the tooltip card opts back in via
// `pointer-events-auto` below. This guarantees that every pixel of
// the underlying page — both inside AND outside the spotlight —
// passes pointer events through normally; the tour is purely a
// visual overlay with its own self-contained controls.
className="pointer-events-none fixed inset-0 z-[200]"
>
{/* Spotlight outline — purely visual ring around the highlighted
target. `pointer-events-none` so a click on the ring still
reaches the underlying element. */}
target. */}
{rect ? (
<div
aria-hidden="true"
@@ -422,39 +431,35 @@ export function OnboardingTour({
/>
) : 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. */}
{/* Dim panels — purely visual. Each panel sits OUTSIDE the spotlight
rect so the spotlighted target keeps its full brightness. The
panels are `pointer-events-none` so the page underneath stays
fully clickable: the user can drive the Hinzufügen dropdown,
sidebar links, or header avatar mid-tour and only the
tooltip-footer Skip / Back / Next buttons drive tour state. */}
{dimRects
? dimRects.map((r, idx) => (
<button
<div
key={idx}
type="button"
aria-label={t("onboarding.tour.skip")}
aria-hidden="true"
data-testid="onboarding-tour-dim"
className={`pointer-events-auto absolute ${backdropClass}`}
className={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")}
// Center-placement fallback — no spotlight target available.
// Render a single full-cover dim layer. Still purely visual:
// skip / back / next live on the tooltip itself.
<div
aria-hidden="true"
data-testid="onboarding-tour-dim"
className={`pointer-events-auto absolute inset-0 ${backdropClass}`}
onClick={handleSkip}
className={`${backdropClass} inset-0`}
/>
)}
@@ -468,9 +473,11 @@ export function OnboardingTour({
{t(stop.titleKey)}
</p>
{/* 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). */}
{/* Tooltip card — the only interactive surface in the tour.
`pointer-events-auto` opts back into click capture against the
root's `pointer-events-none`, so the card's buttons (Skip /
Back / Next) work while every other pixel of the overlay
passes clicks through to the underlying page. */}
<div
ref={tooltipRef}
data-testid="onboarding-tour-tooltip"
@@ -481,9 +488,6 @@ export function OnboardingTour({
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>