mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
6ccb7b3c65
commit
ab1700a843
4 files changed
+288
-73
No files matched your search
@@ -0,0 +1,124 @@
|
||||
# v1.4.33 F2 — onboarding tour click-blocker, real fix
|
||||
|
||||
## Scope
|
||||
|
||||
Phase-3-prep flagged that the F2 e2e regression spec at
|
||||
`e2e/onboarding-tour-passthrough.spec.ts` still fails on both
|
||||
desktop and mobile after the first attempt at the fix
|
||||
(commit `f9b8f3bd` — `fix(onboarding): stop spotlight tour from
|
||||
intercepting dashboard clicks`). This report covers the diagnosis,
|
||||
the real fix, and the hardened regression guard that now ships on
|
||||
develop.
|
||||
|
||||
## Root cause
|
||||
|
||||
The audit finding (`.planning/round-v1433-audit-runtime.md` F2) was
|
||||
that the spotlight tour rendered a single full-viewport `<button>`
|
||||
with a `clip-path` punching a visual hole around the spotlight.
|
||||
`clip-path` only affects painting — the button's hit-box still
|
||||
covered every pixel, so clicks aimed at the underlying page hit the
|
||||
dim layer and ran `handleSkip` instead of the intended target. The
|
||||
worst-case symptom: a brand-new user who landed on the dashboard
|
||||
with `onboarding_tour_completed=false` could not click the header
|
||||
"Add" dropdown until the tour was dismissed.
|
||||
|
||||
The initial fix split the single backdrop into up to four
|
||||
rectangular dim panels (top / bottom / left / right strips around
|
||||
the spotlight rect), set `pointer-events: none` on the tour root,
|
||||
and opted each panel back in with `pointer-events: auto` plus its
|
||||
own `onClick={handleSkip}`. The intent was: keep the spotlighted
|
||||
target clickable, keep the rest of the dim dismissable on tap.
|
||||
|
||||
The flaw: the dashboard's tour-step 1 spotlights the tile-strip,
|
||||
which sits in the middle of the page, while the header "Add"
|
||||
button sits at the top, 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 same click that the
|
||||
audit named as the regression target was still being eaten, just
|
||||
by a smaller rectangle. The four-panel split addressed exactly
|
||||
ONE coordinate (inside the spotlight rect) and broke every other
|
||||
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.
|
||||
|
||||
The forensic check is straightforward: at the centre of the
|
||||
quick-add button, `document.elementsFromPoint` returns the tour's
|
||||
top dim `<button>` above the actual quick-add button. The dim's
|
||||
`getComputedStyle(...).pointerEvents` is `"auto"`. The click
|
||||
follows the topmost interactive element. Result: skip fires, the
|
||||
dropdown never opens.
|
||||
|
||||
## The shipped fix
|
||||
|
||||
The real contract — the one Joyride, Shepherd, and Intro.js have
|
||||
shipped for years — is that the dim layer is purely visual. The
|
||||
whole tour overlay 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. The page
|
||||
underneath stays fully usable while the tour is up. Users dismiss
|
||||
via the explicit "Skip tour" button (or the keyboard's `Esc`,
|
||||
which was already wired).
|
||||
|
||||
Concretely in `src/components/onboarding/tour.tsx`:
|
||||
|
||||
- The 4-panel split survives because it still serves the visual
|
||||
purpose of NOT dimming the spotlighted target rectangle, but
|
||||
each panel is now a `<div aria-hidden="true">` with
|
||||
`pointer-events: none`. The `onClick={handleSkip}` and
|
||||
`aria-label` came off — the panels carry no interactive
|
||||
semantics any more.
|
||||
- The center-placement fallback (no spotlight target available)
|
||||
follows the same shape: a single full-cover dim `<div>` that is
|
||||
purely visual.
|
||||
- The tooltip card keeps `pointer-events: auto` and remains the
|
||||
one and only interactive surface in the tour layer; its
|
||||
`onClick={(e) => e.stopPropagation()}` came off (nothing to
|
||||
stop bubbling to any more — the dim doesn't listen).
|
||||
- Comments on the file now spell out WHY the dim is purely
|
||||
visual, in plain terms, with a reference to the failed
|
||||
intermediate attempt so future hands don't reach for the same
|
||||
rectangular-cover approach again.
|
||||
|
||||
## E2E spec hardening
|
||||
|
||||
`e2e/onboarding-tour-passthrough.spec.ts` now carries three test
|
||||
cases per viewport (chromium-desktop + chromium-mobile = 6 total):
|
||||
|
||||
1. **Hinzufügen dropdown opens with the tour mounted on the
|
||||
dashboard.** Forensic guard 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"`. The actual click on the
|
||||
button then asserts `aria-expanded="true"` and the dropdown
|
||||
menu items appear. Pinpoints the regression instead of just
|
||||
observing the symptom.
|
||||
2. **Explicit Skip button in the tooltip dismisses the tour.**
|
||||
Replaces the previous "click a dim strip skips the tour" case
|
||||
— that contract is gone now (dim is non-interactive). The
|
||||
canonical skip surface is the footer button.
|
||||
3. **Dim panels render as non-interactive visual layers.** Reads
|
||||
computed `pointer-events` from every
|
||||
`[data-testid="onboarding-tour-dim"]` and asserts each is
|
||||
`"none"`. This is the load-bearing invariant that the F2
|
||||
regression violated.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` — 0 errors
|
||||
- `pnpm exec eslint src/components/onboarding/tour.tsx src/components/onboarding/__tests__/tour.test.tsx e2e/onboarding-tour-passthrough.spec.ts` — clean
|
||||
- `pnpm test src/components/onboarding src/lib/onboarding` — 43/43 pass
|
||||
- `pnpm exec playwright test e2e/onboarding-tour-passthrough.spec.ts` — 6/6 pass (3 × desktop + 3 × mobile)
|
||||
|
||||
The pre-existing flakes outside F2 (onboarding-flicker × 2,
|
||||
mobile-viewport × 1) stay as-is — they sit on other agents'
|
||||
fileset for v1.4.33.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/components/onboarding/tour.tsx` — dim panels turned into
|
||||
non-interactive `<div>`s; root pointer-events comments rewritten;
|
||||
tooltip card's `stopPropagation` removed.
|
||||
- `e2e/onboarding-tour-passthrough.spec.ts` — three-case rewrite
|
||||
with `elementsFromPoint` forensic guard.
|
||||
- `src/components/onboarding/__tests__/tour.test.tsx` — comment
|
||||
refresh so the unit test's commentary matches the new contract.
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user