From d0a4a9f834dd1b0a5436dad90ad1a4f5a96c208d Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Fri, 14 Aug 2026 10:35:03 -0400 Subject: [PATCH 01/21] feat(persistence): add editor-tour user preference --- src/core/persistence/userPreferences.test.ts | 17 +++++++++++++++++ src/core/persistence/userPreferences.ts | 11 +++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/core/persistence/userPreferences.test.ts diff --git a/src/core/persistence/userPreferences.test.ts b/src/core/persistence/userPreferences.test.ts new file mode 100644 index 000000000..cf1e64909 --- /dev/null +++ b/src/core/persistence/userPreferences.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test' +import { Value } from '@sinclair/typebox/value' +import { USER_PREFERENCE_KEYS, USER_PREFERENCE_SCHEMAS } from './userPreferences' + +describe('editor-tour preference', () => { + it('is whitelisted with a schema', () => { + expect(USER_PREFERENCE_KEYS).toContain('editor-tour') + expect(USER_PREFERENCE_SCHEMAS['editor-tour']).toBeDefined() + }) + it('accepts completed/dismissed, rejects anything else', () => { + const schema = USER_PREFERENCE_SCHEMAS['editor-tour'] + expect(Value.Check(schema, { status: 'completed' })).toBe(true) + expect(Value.Check(schema, { status: 'dismissed' })).toBe(true) + expect(Value.Check(schema, { status: 'seen' })).toBe(false) + expect(Value.Check(schema, {})).toBe(false) + }) +}) diff --git a/src/core/persistence/userPreferences.ts b/src/core/persistence/userPreferences.ts index 0e85a208b..fa5ed6068 100644 --- a/src/core/persistence/userPreferences.ts +++ b/src/core/persistence/userPreferences.ts @@ -98,6 +98,15 @@ export const DEFAULT_MODULE_INSERTER_PREFERENCE: ModuleInserterPreference = { ], } +/** + * Editor tour outcome. Never-set (null from the server) = the user has not + * seen the tour; the site editor auto-starts it in that case. + */ +export const EditorTourPreferenceSchema = Type.Object({ + status: Type.Union([Type.Literal('completed'), Type.Literal('dismissed')]), +}) +export type EditorTourPreference = Static + // --------------------------------------------------------------------------- // Whitelist // --------------------------------------------------------------------------- @@ -117,6 +126,7 @@ export const DEFAULT_MODULE_INSERTER_PREFERENCE: ModuleInserterPreference = { export const USER_PREFERENCE_KEYS = [ 'dashboard-layout', 'module-inserter', + 'editor-tour', ] as const export type UserPreferenceKey = (typeof USER_PREFERENCE_KEYS)[number] @@ -130,6 +140,7 @@ export type UserPreferenceKey = (typeof USER_PREFERENCE_KEYS)[number] export const USER_PREFERENCE_SCHEMAS = { 'dashboard-layout': DashboardLayoutSchema, 'module-inserter': ModuleInserterPreferenceSchema, + 'editor-tour': EditorTourPreferenceSchema, } as const satisfies Record type UserPreferenceValue = Static< From c003e152b85f3d18dde2e9daa74e4a9618290672 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Fri, 14 Aug 2026 10:39:58 -0400 Subject: [PATCH 02/21] test(persistence): move editor-tour pref tests to the shared suite --- .../persistence/userPreferences.test.ts | 28 +++++++++++++++++++ src/core/persistence/userPreferences.test.ts | 17 ----------- 2 files changed, 28 insertions(+), 17 deletions(-) delete mode 100644 src/core/persistence/userPreferences.test.ts diff --git a/src/__tests__/persistence/userPreferences.test.ts b/src/__tests__/persistence/userPreferences.test.ts index f3484a30d..b5474eb4c 100644 --- a/src/__tests__/persistence/userPreferences.test.ts +++ b/src/__tests__/persistence/userPreferences.test.ts @@ -47,3 +47,31 @@ describe('user preference schemas', () => { }) }) }) + +describe('editor-tour preference', () => { + it('whitelists the editor tour preference key', () => { + expect(USER_PREFERENCE_KEYS).toContain('editor-tour') + }) + + it('accepts a completed status', () => { + expect(parseValue(USER_PREFERENCE_SCHEMAS['editor-tour'], { status: 'completed' })).toEqual({ + status: 'completed', + }) + }) + + it('accepts a dismissed status', () => { + expect(parseValue(USER_PREFERENCE_SCHEMAS['editor-tour'], { status: 'dismissed' })).toEqual({ + status: 'dismissed', + }) + }) + + it('rejects an unknown status', () => { + expect(safeParseValue(USER_PREFERENCE_SCHEMAS['editor-tour'], { status: 'seen' }).ok).toBe( + false, + ) + }) + + it('rejects a missing status', () => { + expect(safeParseValue(USER_PREFERENCE_SCHEMAS['editor-tour'], {}).ok).toBe(false) + }) +}) diff --git a/src/core/persistence/userPreferences.test.ts b/src/core/persistence/userPreferences.test.ts deleted file mode 100644 index cf1e64909..000000000 --- a/src/core/persistence/userPreferences.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { Value } from '@sinclair/typebox/value' -import { USER_PREFERENCE_KEYS, USER_PREFERENCE_SCHEMAS } from './userPreferences' - -describe('editor-tour preference', () => { - it('is whitelisted with a schema', () => { - expect(USER_PREFERENCE_KEYS).toContain('editor-tour') - expect(USER_PREFERENCE_SCHEMAS['editor-tour']).toBeDefined() - }) - it('accepts completed/dismissed, rejects anything else', () => { - const schema = USER_PREFERENCE_SCHEMAS['editor-tour'] - expect(Value.Check(schema, { status: 'completed' })).toBe(true) - expect(Value.Check(schema, { status: 'dismissed' })).toBe(true) - expect(Value.Check(schema, { status: 'seen' })).toBe(false) - expect(Value.Check(schema, {})).toBe(false) - }) -}) From c749eb66bea8e97944b8f3d8ff04435358d43604 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sat, 15 Aug 2026 17:39:42 -0400 Subject: [PATCH 03/21] feat(admin): tour engine store and step types --- src/admin/shared/tour/index.ts | 2 + src/admin/shared/tour/tourStore.test.ts | 85 +++++++++++++++++++++++++ src/admin/shared/tour/tourStore.ts | 61 ++++++++++++++++++ src/admin/shared/tour/types.ts | 28 ++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/admin/shared/tour/index.ts create mode 100644 src/admin/shared/tour/tourStore.test.ts create mode 100644 src/admin/shared/tour/tourStore.ts create mode 100644 src/admin/shared/tour/types.ts diff --git a/src/admin/shared/tour/index.ts b/src/admin/shared/tour/index.ts new file mode 100644 index 000000000..50edb03a5 --- /dev/null +++ b/src/admin/shared/tour/index.ts @@ -0,0 +1,2 @@ +export { useTourStore } from './tourStore' +export type { TourOutcome, TourStepDef } from './types' diff --git a/src/admin/shared/tour/tourStore.test.ts b/src/admin/shared/tour/tourStore.test.ts new file mode 100644 index 000000000..208542441 --- /dev/null +++ b/src/admin/shared/tour/tourStore.test.ts @@ -0,0 +1,85 @@ +/** + * tourStore — coverage for start/next/back/dismiss/complete and the + * outcome callback. Colocated with the store per `src/admin/` convention + * (see OnboardingPanel.test.tsx). + */ +import { beforeEach, describe, expect, it, mock } from 'bun:test' +import { useTourStore } from './tourStore' +import type { TourStepDef } from './types' + +const steps: TourStepDef[] = [ + { id: 'a', anchor: null, title: 'A', body: 'a' }, + { id: 'b', anchor: 'x', title: 'B', body: 'b' }, +] + +beforeEach(() => useTourStore.setState({ steps: null, stepIndex: 0, onEnd: null })) + +describe('tourStore', () => { + it('start() sets steps at index 0 and throws on empty', () => { + const onEnd = mock() + useTourStore.getState().start(steps, onEnd) + + const state = useTourStore.getState() + expect(state.steps).toBe(steps) + expect(state.stepIndex).toBe(0) + expect(onEnd).not.toHaveBeenCalled() + + expect(() => useTourStore.getState().start([], onEnd)).toThrow('Tour needs at least one step') + }) + + it('back() at 0 is a no-op; next() advances; next() at end completes with outcome', () => { + const onEnd = mock() + useTourStore.getState().start(steps, onEnd) + + useTourStore.getState().back() + expect(useTourStore.getState().stepIndex).toBe(0) + + useTourStore.getState().next() + expect(useTourStore.getState().stepIndex).toBe(1) + expect(useTourStore.getState().steps).toBe(steps) + + useTourStore.getState().next() + const state = useTourStore.getState() + expect(state.steps).toBeNull() + expect(state.stepIndex).toBe(0) + expect(state.onEnd).toBeNull() + expect(onEnd).toHaveBeenCalledTimes(1) + expect(onEnd).toHaveBeenCalledWith('completed') + }) + + it('dismiss() ends with dismissed outcome', () => { + const onEnd = mock() + useTourStore.getState().start(steps, onEnd) + + useTourStore.getState().dismiss() + + const state = useTourStore.getState() + expect(state.steps).toBeNull() + expect(state.stepIndex).toBe(0) + expect(state.onEnd).toBeNull() + expect(onEnd).toHaveBeenCalledTimes(1) + expect(onEnd).toHaveBeenCalledWith('dismissed') + }) + + it('complete() ends with completed outcome even before the last step', () => { + const onEnd = mock() + useTourStore.getState().start(steps, onEnd) + + useTourStore.getState().complete() + + const state = useTourStore.getState() + expect(state.steps).toBeNull() + expect(state.stepIndex).toBe(0) + expect(onEnd).toHaveBeenCalledTimes(1) + expect(onEnd).toHaveBeenCalledWith('completed') + }) + + it('next() and back() are no-ops when no tour is running', () => { + useTourStore.getState().next() + useTourStore.getState().back() + + const state = useTourStore.getState() + expect(state.steps).toBeNull() + expect(state.stepIndex).toBe(0) + }) +}) diff --git a/src/admin/shared/tour/tourStore.ts b/src/admin/shared/tour/tourStore.ts new file mode 100644 index 000000000..77c4a1c1b --- /dev/null +++ b/src/admin/shared/tour/tourStore.ts @@ -0,0 +1,61 @@ +/** + * tourStore — generic coach-mark tour engine. + * + * Holds the currently-running tour (if any) and the active step index. + * `start()` is called by an editor-specific tour launcher with a + * `TourStepDef[]` and an `onEnd` callback; `next` / `back` / `dismiss` / + * `complete` drive it from there. Ending the tour (falling off the last + * step via `next()`, or explicit `dismiss()` / `complete()`) clears the + * running state and fires `onEnd` exactly once with the outcome. + * + * Kept editor-agnostic on purpose: no imports from `@site/*`, persistence, + * or spotlight/overlay modules. The overlay component that renders steps + * from this store lives alongside it in this folder; anything that knows + * about a specific editor's panels/anchors is the caller's job via + * `TourStepDef.prepare`. + */ +import { create } from 'zustand' +import type { TourOutcome, TourStepDef } from './types' + +interface TourState { + steps: TourStepDef[] | null + stepIndex: number + onEnd: ((outcome: TourOutcome) => void) | null + /** Begins a tour. Throws if `steps` is empty. */ + start: (steps: TourStepDef[], onEnd: (outcome: TourOutcome) => void) => void + /** Advances to the next step, or completes the tour on the last step. */ + next: () => void + /** Moves back one step. No-op on the first step. */ + back: () => void + /** Ends the tour early with a "dismissed" outcome. */ + dismiss: () => void + /** Ends the tour with a "completed" outcome. */ + complete: () => void +} + +export const useTourStore = create()((set, get) => { + function end(outcome: TourOutcome) { + const { onEnd } = get() + set({ steps: null, stepIndex: 0, onEnd: null }) + onEnd?.(outcome) + } + + return { + steps: null, + stepIndex: 0, + onEnd: null, + start: (steps, onEnd) => { + if (steps.length === 0) throw new Error('Tour needs at least one step') + set({ steps, stepIndex: 0, onEnd }) + }, + next: () => { + const { steps, stepIndex } = get() + if (!steps) return + if (stepIndex >= steps.length - 1) return end('completed') + set({ stepIndex: stepIndex + 1 }) + }, + back: () => set((state) => ({ stepIndex: Math.max(0, state.stepIndex - 1) })), + dismiss: () => end('dismissed'), + complete: () => end('completed'), + } +}) diff --git a/src/admin/shared/tour/types.ts b/src/admin/shared/tour/types.ts new file mode 100644 index 000000000..dad75c624 --- /dev/null +++ b/src/admin/shared/tour/types.ts @@ -0,0 +1,28 @@ +/** + * tour/types — shape of a generic coach-mark tour. + * + * A tour is an ordered list of `TourStepDef`s driven by `tourStore`. This + * module knows nothing about any specific editor, panel, or feature — it is + * the vocabulary a caller uses to describe "show this bubble, pointing at + * this element, with this copy." Editor-specific tours (e.g. the Site editor + * onboarding tour) live elsewhere and build `TourStepDef[]` arrays against + * this type; this module stays reusable across every future tour. + */ +import type { FloatingAlign, FloatingSide } from '@ui/lib/floatingPosition' + +/** How a tour ended — passed to the `onEnd` callback given to `start()`. */ +export type TourOutcome = 'completed' | 'dismissed' + +/** One step in a tour. */ +export interface TourStepDef { + /** Stable identifier for the step (analytics, debugging). */ + id: string + /** data-testid of the anchor element; null = centered step (welcome/finish). */ + anchor: string | null + title: string + body: string + side?: FloatingSide + align?: FloatingAlign + /** Runs before the step shows — open the panel that contains the anchor, etc. */ + prepare?: () => void | Promise +} From 33912d83ee96eb496bda45c8b1bbcef0a070d19b Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sat, 15 Aug 2026 17:54:27 -0400 Subject: [PATCH 04/21] feat(admin): TourOverlay coach-mark renderer with spotlight --- src/admin/shared/tour/TourOverlay.module.css | 135 ++++++++ src/admin/shared/tour/TourOverlay.test.tsx | 104 +++++++ src/admin/shared/tour/TourOverlay.tsx | 306 +++++++++++++++++++ src/admin/shared/tour/index.ts | 1 + src/styles/globals.css | 8 + 5 files changed, 554 insertions(+) create mode 100644 src/admin/shared/tour/TourOverlay.module.css create mode 100644 src/admin/shared/tour/TourOverlay.test.tsx create mode 100644 src/admin/shared/tour/TourOverlay.tsx diff --git a/src/admin/shared/tour/TourOverlay.module.css b/src/admin/shared/tour/TourOverlay.module.css new file mode 100644 index 000000000..540a97a3e --- /dev/null +++ b/src/admin/shared/tour/TourOverlay.module.css @@ -0,0 +1,135 @@ +/* + * TourOverlay — dim backdrop + spotlight cutout + positioned coach-mark + * bubble. Portal-rendered to document.body; both pieces are position: + * fixed and stack via --tour-z-index (see src/styles/globals.css). + * + * Bubble position is applied via CSS custom properties injected through + * inline style (the one sanctioned use of inline `style`): + * --tour-x / --tour-y translate offset from top-left, for anchored steps + * + * Centered steps (no anchor) ignore --tour-x/--tour-y entirely and are + * placed by the `[data-position="centered"]` override below instead. + * + * No !important, no hardcoded colours, no Tailwind utilities. + */ + +/* ── Backdrop ───────────────────────────────────────────────────────────── */ + +.backdrop { + position: fixed; + inset: 0; + z-index: var(--tour-z-index); + background: var(--scrim-60); + animation: tourBackdropIn 160ms ease-out; +} + +/* Spotlight mode dims via the SVG's masked rect instead of a flat scrim, so + the cutout can be a soft rounded rect rather than a hard rectangle. */ +.backdrop[data-anchored] { + background: transparent; +} + +.spotlightSvg { + position: fixed; + inset: 0; + width: 100%; + height: 100%; +} + +.spotlightDim { + fill: var(--scrim-60); +} + +.spotlightHole { + transition: x 160ms ease, y 160ms ease, width 160ms ease, height 160ms ease; +} + +@keyframes tourBackdropIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* ── Bubble ─────────────────────────────────────────────────────────────── */ + +.bubble { + position: fixed; + top: 0; + left: 0; + --tour-x: 0px; + --tour-y: 0px; + transform: translate3d(var(--tour-x), var(--tour-y), 0); + z-index: calc(var(--tour-z-index) + 1); + + display: flex; + flex-direction: column; + gap: var(--space-m); + width: min(320px, calc(100vw - var(--space-4xl))); + padding: var(--space-xl) var(--space-2xl); + + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--panel-radius); + box-shadow: var(--shadow-panel); + color: var(--text); + + transition: transform 160ms ease, opacity 120ms ease; +} + +.bubble[data-position='centered'] { + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0); +} + +.progress { + margin: 0; + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-subtle); +} + +.title { + margin: 0; + font-size: var(--text-xl); + font-weight: 700; + line-height: 1.3; + color: var(--text-bright); +} + +.body { + margin: 0; + font-size: var(--text-s); + line-height: 1.5; + color: var(--text-muted); +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-m); + margin-top: var(--space-xs); +} + +.navActions { + display: flex; + align-items: center; + gap: var(--space-s); +} + +/* ── Reduced motion ─────────────────────────────────────────────────────── */ +/* Durations are already clamped globally (see src/styles/globals.css), this + just drops the backdrop's entrance animation entirely rather than let it + play at 0.01ms. */ + +@media (prefers-reduced-motion: reduce) { + .backdrop { + animation: none; + } +} diff --git a/src/admin/shared/tour/TourOverlay.test.tsx b/src/admin/shared/tour/TourOverlay.test.tsx new file mode 100644 index 000000000..716650044 --- /dev/null +++ b/src/admin/shared/tour/TourOverlay.test.tsx @@ -0,0 +1,104 @@ +/** + * TourOverlay — coverage for the idle/active render split, step + * progression, Escape dismiss, and the anchor-missing soft-skip. Colocated + * with the component per `src/admin/` convention (see + * OnboardingPanel.test.tsx). + */ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { TourOverlay } from './TourOverlay' +import { useTourStore } from './tourStore' +import type { TourStepDef } from './types' + +const anchors: HTMLElement[] = [] + +function createAnchor(testId: string): HTMLElement { + const el = document.createElement('div') + el.setAttribute('data-testid', testId) + document.body.appendChild(el) + anchors.push(el) + return el +} + +afterEach(() => { + cleanup() + useTourStore.setState({ steps: null, stepIndex: 0, onEnd: null }) + anchors.splice(0).forEach((el) => el.remove()) +}) + +describe('TourOverlay', () => { + it('renders nothing when idle', () => { + render() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('shows an anchored bubble, then advances to a centered step whose button reads "Finish"', async () => { + createAnchor('target-a') + const steps: TourStepDef[] = [ + { id: 'a', anchor: 'target-a', title: 'First step', body: 'Body A' }, + { id: 'b', anchor: null, title: 'Last step', body: 'Body B' }, + ] + const onEnd = mock() + + render() + act(() => { + useTourStore.getState().start(steps, onEnd) + }) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Step 1 of 2')).toBeTruthy() + expect(within(dialog).getByText('First step')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + await waitFor(() => { + expect(within(screen.getByRole('dialog')).getByText('Step 2 of 2')).toBeTruthy() + }) + expect(within(screen.getByRole('dialog')).getByText('Last step')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Finish' })).toBeTruthy() + expect(onEnd).not.toHaveBeenCalled() + }) + + it('Escape dismisses the tour and onEnd receives "dismissed"', async () => { + createAnchor('target-esc') + const steps: TourStepDef[] = [{ id: 'a', anchor: 'target-esc', title: 'Step', body: 'Body' }] + const onEnd = mock() + + render() + act(() => { + useTourStore.getState().start(steps, onEnd) + }) + + await screen.findByRole('dialog') + + fireEvent.keyDown(window, { key: 'Escape' }) + + await waitFor(() => expect(onEnd).toHaveBeenCalledWith('dismissed')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('soft-skips a step whose anchor never appears, warns, and lands on the following step', async () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + const steps: TourStepDef[] = [ + { id: 'missing', anchor: 'does-not-exist', title: 'Missing', body: 'Body' }, + { id: 'centered', anchor: null, title: 'Centered', body: 'Body' }, + ] + const onEnd = mock() + + render() + act(() => { + useTourStore.getState().start(steps, onEnd) + }) + + await waitFor( + () => { + expect(within(screen.getByRole('dialog')).getByText('Centered')).toBeTruthy() + }, + { timeout: 4000 }, + ) + expect(warnSpy).toHaveBeenCalledWith('[tour] anchor missing, skipping step:', 'missing') + expect(onEnd).not.toHaveBeenCalled() + + warnSpy.mockRestore() + }) +}) diff --git a/src/admin/shared/tour/TourOverlay.tsx b/src/admin/shared/tour/TourOverlay.tsx new file mode 100644 index 000000000..907506ef1 --- /dev/null +++ b/src/admin/shared/tour/TourOverlay.tsx @@ -0,0 +1,306 @@ +/** + * TourOverlay — coach-mark renderer for `useTourStore`. + * + * Renders nothing while idle. When a tour is running, portals a dim + * backdrop — an SVG spotlight cutout around the active step's anchor, or a + * plain scrim for centered steps — plus a positioned bubble (progress, + * title, body, Skip/Back/Next) to `document.body`. + * + * Three-component shape: + * - `TourOverlay` bails out before any hooks run when there's nothing to + * show (same trick as `Tooltip`'s `disabled` path), so the idle render + * stays hook-free without violating the rules of hooks. + * - `TourOverlayInner` owns the store subscriptions that live for the + * whole tour (current step index, the Escape-to-dismiss listener). + * - `TourStep`, remounted via `key={stepIndex}` on every step change, owns + * the per-step lifecycle: `step.prepare?.()`, then locating the anchor + * (or going straight to centered). Remounting instead of resetting + * state in an effect means each step starts from real `useState` + * initial values — no imperative "clear the previous step's state" + * effect needed. + * + * A step that never finds its anchor is soft-skipped: `waitForAnchor` polls + * `[data-testid=""]` for up to two seconds, then — on timeout — + * logs and advances past it rather than leaving the tour stuck on a target + * that never appeared. + */ +import { + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, +} from 'react' +import { createPortal } from 'react-dom' +import { Button } from '@ui/components/Button' +import { computeFloatingPosition, type ResolvedFloatingSide } from '@ui/lib/floatingPosition' +import { useTourStore } from './tourStore' +import type { TourStepDef } from './types' +import styles from './TourOverlay.module.css' + +/** How long a step waits for its anchor before soft-skipping. */ +const ANCHOR_WAIT_TIMEOUT_MS = 2000 +/** Outward inflation of the spotlight cutout past the anchor's own rect. */ +const SPOTLIGHT_INFLATE = 6 +const SPOTLIGHT_RADIUS = 8 +const BUBBLE_OFFSET = 12 +const BUBBLE_EDGE_PADDING = 16 +const BUBBLE_AUTO_PRIORITY = ['bottom', 'top', 'right', 'left'] as const + +/** + * Polls `document.querySelector('[data-testid=""]')` once per + * animation frame until the element appears or `timeoutMs` elapses. + */ +const waitForAnchor = (testId: string, timeoutMs: number): Promise => + new Promise((resolve) => { + const deadline = performance.now() + timeoutMs + const poll = () => { + const el = document.querySelector(`[data-testid="${testId}"]`) + if (el) { + resolve(el) + return + } + if (performance.now() >= deadline) { + resolve(null) + return + } + requestAnimationFrame(poll) + } + poll() + }) + +interface BubblePosition { + x: number + y: number + side: ResolvedFloatingSide +} + +export const TourOverlay = () => { + const steps = useTourStore((s) => s.steps) + if (steps === null) return null + return +} + +const TourOverlayInner = ({ steps }: { steps: TourStepDef[] }) => { + const stepIndex = useTourStore((s) => s.stepIndex) + const next = useTourStore((s) => s.next) + const back = useTourStore((s) => s.back) + const dismiss = useTourStore((s) => s.dismiss) + + // Escape dismisses the tour from anywhere while it's active — the whole + // point of a passive coach mark is that the user can bail without + // hunting for a close button. Lives here (not in `TourStep`, which + // remounts every step) so it stays subscribed across step changes. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + dismiss() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [dismiss]) + + return ( + + ) +} + +interface TourStepProps { + step: TourStepDef + stepNumber: number + totalSteps: number + isFirstStep: boolean + isLastStep: boolean + onNext: () => void + onBack: () => void + onDismiss: () => void +} + +const TourStep = ({ + step, + stepNumber, + totalSteps, + isFirstStep, + isLastStep, + onNext, + onBack, + onDismiss, +}: TourStepProps) => { + const titleId = useId() + const maskId = `${titleId}-mask` + const bubbleRef = useRef(null) + + const [anchorEl, setAnchorEl] = useState(null) + const [anchorRect, setAnchorRect] = useState(null) + const [ready, setReady] = useState(false) + const [position, setPosition] = useState(null) + + // Locate this step's target: run `prepare()`, then either go straight to + // "ready" (centered step) or wait for the anchor to appear. `cancelled` + // guards against this component unmounting (the store moved to a + // different step, or ended the tour) while the wait is still pending. + useEffect(() => { + let cancelled = false + + const run = async () => { + await step.prepare?.() + if (cancelled) return + + if (step.anchor === null) { + setReady(true) + return + } + + const el = await waitForAnchor(step.anchor, ANCHOR_WAIT_TIMEOUT_MS) + if (cancelled) return + + if (!el) { + console.warn('[tour] anchor missing, skipping step:', step.id) + onNext() + return + } + + setAnchorEl(el) + setAnchorRect(el.getBoundingClientRect()) + setReady(true) + } + + run() + return () => { + cancelled = true + } + }, [step, onNext]) + + // Re-measure the anchor's rect on resize, scroll, or its own layout + // changes, so the spotlight cutout and bubble position stay glued to it. + useEffect(() => { + if (!anchorEl) return + const measure = () => setAnchorRect(anchorEl.getBoundingClientRect()) + const observers: ResizeObserver[] = [] + if (typeof ResizeObserver !== 'undefined') { + const anchorObserver = new ResizeObserver(measure) + anchorObserver.observe(anchorEl) + observers.push(anchorObserver) + if (bubbleRef.current) { + const bubbleObserver = new ResizeObserver(measure) + bubbleObserver.observe(bubbleRef.current) + observers.push(bubbleObserver) + } + } + window.addEventListener('resize', measure) + // Capture phase: any scrollable ancestor (not just window) can move the + // anchor, and scroll events don't bubble. + document.addEventListener('scroll', measure, true) + return () => { + observers.forEach((observer) => observer.disconnect()) + window.removeEventListener('resize', measure) + document.removeEventListener('scroll', measure, true) + } + }, [anchorEl]) + + // Compute the bubble's floating position once it's measurable. Centered + // steps (no anchor, `anchorRect` stays null) are placed by CSS alone. + useLayoutEffect(() => { + if (!ready || !anchorRect) return + const bubbleEl = bubbleRef.current + if (!bubbleEl) return + const { width, height } = bubbleEl.getBoundingClientRect() + const computed = computeFloatingPosition(anchorRect, { + floatingWidth: width, + floatingHeight: height, + side: step.side ?? 'auto', + align: step.align ?? 'center', + offset: BUBBLE_OFFSET, + edgePadding: BUBBLE_EDGE_PADDING, + autoPriority: BUBBLE_AUTO_PRIORITY, + }) + setPosition({ x: computed.x, y: computed.y, side: computed.side }) + }, [ready, anchorRect, step.side, step.align]) + + // Focus the bubble once it mounts so screen readers announce it and + // keyboard focus doesn't stay pinned to whatever was focused before. + useEffect(() => { + if (!ready) return + bubbleRef.current?.focus() + }, [ready]) + + if (!ready) return null + + const anchored = anchorRect !== null + + const bubbleStyle = { + '--tour-x': position ? `${position.x}px` : '0px', + '--tour-y': position ? `${position.y}px` : '0px', + } as CSSProperties + + return createPortal( + <> +
+ {anchored && anchorRect && ( + + )} +
+
+

+ Step {stepNumber} of {totalSteps} +

+

+ {step.title} +

+

{step.body}

+
+ +
+ {!isFirstStep && ( + + )} + +
+
+
+ , + document.body, + ) +} diff --git a/src/admin/shared/tour/index.ts b/src/admin/shared/tour/index.ts index 50edb03a5..7bb47c1d7 100644 --- a/src/admin/shared/tour/index.ts +++ b/src/admin/shared/tour/index.ts @@ -1,2 +1,3 @@ export { useTourStore } from './tourStore' export type { TourOutcome, TourStepDef } from './types' +export { TourOverlay } from './TourOverlay' diff --git a/src/styles/globals.css b/src/styles/globals.css index 3c4839749..a976fef9d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -291,6 +291,14 @@ --spotlight-width: 640px; --progress-shimmer: var(--overlay-40); + /* Editor onboarding tour. Sits above every editor surface (dialogs, + * panels, the command-palette spotlight) so a coach mark is never + * occluded mid-tour, but stays below toasts/tooltips — a toast error + * should still interrupt the tour, and a tooltip must always render on + * top. The bubble itself renders one layer above this so it's never + * covered by its own backdrop. */ + --tour-z-index: 9500; + /* Bespoke surface tints */ --plugins-hero-tint-a: rgba(35, 92, 72, 0.32); --plugins-hero-tint-b: rgba(49, 64, 94, 0.24); From 4174dad00d86ea9a465d6cd2d2c535543fe281b1 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sat, 15 Aug 2026 18:01:16 -0400 Subject: [PATCH 05/21] fix(admin): harden TourOverlay against failing prepare and match house style --- src/admin/shared/tour/TourOverlay.test.tsx | 33 ++++++++++++++++++++ src/admin/shared/tour/TourOverlay.tsx | 35 ++++++++++------------ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/admin/shared/tour/TourOverlay.test.tsx b/src/admin/shared/tour/TourOverlay.test.tsx index 716650044..e0c20656d 100644 --- a/src/admin/shared/tour/TourOverlay.test.tsx +++ b/src/admin/shared/tour/TourOverlay.test.tsx @@ -101,4 +101,37 @@ describe('TourOverlay', () => { warnSpy.mockRestore() }) + + it('soft-skips a step whose prepare() rejects, warns, and lands on the following step', async () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + const prepareError = new Error('prepare boom') + const steps: TourStepDef[] = [ + { + id: 'broken', + anchor: null, + title: 'Broken', + body: 'Body', + prepare: () => Promise.reject(prepareError), + }, + { id: 'centered', anchor: null, title: 'Centered', body: 'Body' }, + ] + const onEnd = mock() + + render() + act(() => { + useTourStore.getState().start(steps, onEnd) + }) + + await waitFor(() => { + expect(within(screen.getByRole('dialog')).getByText('Centered')).toBeTruthy() + }) + expect(warnSpy).toHaveBeenCalledWith( + '[tour] prepare failed, skipping step:', + 'broken', + prepareError, + ) + expect(onEnd).not.toHaveBeenCalled() + + warnSpy.mockRestore() + }) }) diff --git a/src/admin/shared/tour/TourOverlay.tsx b/src/admin/shared/tour/TourOverlay.tsx index 907506ef1..f58e7f39e 100644 --- a/src/admin/shared/tour/TourOverlay.tsx +++ b/src/admin/shared/tour/TourOverlay.tsx @@ -52,8 +52,8 @@ const BUBBLE_AUTO_PRIORITY = ['bottom', 'top', 'right', 'left'] as const * Polls `document.querySelector('[data-testid=""]')` once per * animation frame until the element appears or `timeoutMs` elapses. */ -const waitForAnchor = (testId: string, timeoutMs: number): Promise => - new Promise((resolve) => { +function waitForAnchor(testId: string, timeoutMs: number): Promise { + return new Promise((resolve) => { const deadline = performance.now() + timeoutMs const poll = () => { const el = document.querySelector(`[data-testid="${testId}"]`) @@ -69,6 +69,7 @@ const waitForAnchor = (testId: string, timeoutMs: number): Promise { +export function TourOverlay() { const steps = useTourStore((s) => s.steps) if (steps === null) return null return } -const TourOverlayInner = ({ steps }: { steps: TourStepDef[] }) => { +function TourOverlayInner({ steps }: { steps: TourStepDef[] }) { const stepIndex = useTourStore((s) => s.stepIndex) const next = useTourStore((s) => s.next) const back = useTourStore((s) => s.back) @@ -108,8 +109,6 @@ const TourOverlayInner = ({ steps }: { steps: TourStepDef[] }) => { step={steps[stepIndex]} stepNumber={stepIndex + 1} totalSteps={steps.length} - isFirstStep={stepIndex === 0} - isLastStep={stepIndex === steps.length - 1} onNext={next} onBack={back} onDismiss={dismiss} @@ -121,23 +120,14 @@ interface TourStepProps { step: TourStepDef stepNumber: number totalSteps: number - isFirstStep: boolean - isLastStep: boolean onNext: () => void onBack: () => void onDismiss: () => void } -const TourStep = ({ - step, - stepNumber, - totalSteps, - isFirstStep, - isLastStep, - onNext, - onBack, - onDismiss, -}: TourStepProps) => { +function TourStep({ step, stepNumber, totalSteps, onNext, onBack, onDismiss }: TourStepProps) { + const isFirstStep = stepNumber === 1 + const isLastStep = stepNumber === totalSteps const titleId = useId() const maskId = `${titleId}-mask` const bubbleRef = useRef(null) @@ -155,7 +145,14 @@ const TourStep = ({ let cancelled = false const run = async () => { - await step.prepare?.() + try { + await step.prepare?.() + } catch (err) { + console.warn('[tour] prepare failed, skipping step:', step.id, err) + if (cancelled) return + onNext() + return + } if (cancelled) return if (step.anchor === null) { From b8c88d1cfbc82019d94fec64c7541c99a1851d71 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sat, 15 Aug 2026 18:10:57 -0400 Subject: [PATCH 06/21] feat(editor): first-run guided tour with auto-start and persistence --- .../AdminCanvasEditorBody.tsx | 13 +++ src/admin/pages/site/SitePage.tsx | 7 ++ .../SiteExplorerPanelSections.tsx | 1 + .../SiteExplorerTreeSection.tsx | 4 + .../pages/site/tour/editorTourSteps.test.ts | 28 +++++ src/admin/pages/site/tour/editorTourSteps.ts | 102 ++++++++++++++++++ src/admin/pages/site/tour/useEditorTour.ts | 62 +++++++++++ src/admin/spotlight/pendingAction.ts | 1 + 8 files changed, 218 insertions(+) create mode 100644 src/admin/pages/site/tour/editorTourSteps.test.ts create mode 100644 src/admin/pages/site/tour/editorTourSteps.ts create mode 100644 src/admin/pages/site/tour/useEditorTour.ts diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx index faa2f9642..8a89757b2 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx @@ -17,6 +17,8 @@ import { RightSidebar } from '@admin/pages/site/sidebars/RightSidebar' import { selectRightSidebarExpanded, useEditorStore } from '@admin/pages/site/store/store' import { useNarrowEditorChrome } from '@site/layout/responsiveChrome' import { ConfirmDeleteProvider } from '@admin/shared/dialogs/ConfirmDeleteDialog' +import { TourOverlay } from '@admin/shared/tour' +import { useEditorTour } from '@admin/pages/site/tour/useEditorTour' import { Dialog } from '@ui/components/Dialog' import { Button } from '@ui/components/Button' import { cn } from '@ui/cn' @@ -56,6 +58,13 @@ export function AdminCanvasEditorBody({ // their own); lives here, in the lazy body, so the CMS fetch it needs for // postTypes templates stays out of the admin-shell bundle. useActiveLivePath() + // First-run guided tour — auto-starts once (see useEditorTour.ts) and + // replays via the `site.startTour` pending action. This component is the + // Site editor's own lazy body (AdminCanvasLayout is the Site-editor-only + // shell — Content/Data/Media render through AdminWorkspaceCanvasLayout + // instead), so mounting the hook here never fires the tour outside the + // site editor. + useEditorTour() const propertiesPanelMode = useEditorStore((s) => s.propertiesPanelMode) const rightSidebarExpanded = useEditorStore(selectRightSidebarExpanded) @@ -136,6 +145,10 @@ export function AdminCanvasEditorBody({ until a layoutNameDialogRequest is set on the ui slice. */} + {/* First-run guided tour overlay — renders nothing while idle, portals + the coach-mark bubble to document.body once a tour is running. */} + + {/* Import HTML modal — opens from Spotlight or right-click "Paste HTML here…". The modal implementation is rarely used and pulls in the importer, tree preview, and HTML editor, so keep it behind this open-state diff --git a/src/admin/pages/site/SitePage.tsx b/src/admin/pages/site/SitePage.tsx index 698ee74b5..d749351d8 100644 --- a/src/admin/pages/site/SitePage.tsx +++ b/src/admin/pages/site/SitePage.tsx @@ -3,6 +3,7 @@ import { AdminCanvasLayout } from '@admin/layouts/AdminCanvasLayout' import { consumePendingAction } from '@admin/spotlight/pendingAction' import { useEditorStore } from '@site/store/store' import { useMcpWorkspaceBridge } from '@admin/ai/useMcpWorkspaceBridge' +import { startEditorTour } from './tour/useEditorTour' import { executeAgentTool } from './agent' /** @@ -50,6 +51,12 @@ export function SitePage() { return true } + const startTour = consumePendingAction('site.startTour') + if (startTour) { + startEditorTour() + return true + } + return true // hydrated but nothing queued — stop waiting } diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanelSections.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanelSections.tsx index 9fbc718d8..5ad49a7e2 100644 --- a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanelSections.tsx +++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanelSections.tsx @@ -106,6 +106,7 @@ export function SiteExplorerPanelSections({ count={normalPageCount} actionLabel="New page" actionIcon={FilePlusSolidIcon} + actionTestId="site-explorer-new-page" onAction={onCreatePage} model={pageTreeModel} dropTarget={explorerDnd.target} diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeSection.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeSection.tsx index fb9d5f077..ef230d82f 100644 --- a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeSection.tsx +++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeSection.tsx @@ -37,6 +37,8 @@ interface SiteExplorerTreeSectionProps { count: number actionLabel: string actionIcon: IconComponent + /** `data-testid` for the section's primary action button (e.g. tour anchors). */ + actionTestId?: string model: SiteExplorerTreeSectionModel | SiteExplorerStructuralSectionModel dropTarget: SiteExplorerDropTarget | null inlineRenameTarget: SiteExplorerInlineRenameTarget | null @@ -60,6 +62,7 @@ export function SiteExplorerTreeSection({ count, actionLabel, actionIcon, + actionTestId, model, dropTarget, inlineRenameTarget, @@ -128,6 +131,7 @@ export function SiteExplorerTreeSection({ aria-label={actionLabel} tooltip={actionLabel} onClick={onAction} + data-testid={actionTestId} > diff --git a/src/admin/pages/site/tour/editorTourSteps.test.ts b/src/admin/pages/site/tour/editorTourSteps.test.ts new file mode 100644 index 000000000..860aaf52a --- /dev/null +++ b/src/admin/pages/site/tour/editorTourSteps.test.ts @@ -0,0 +1,28 @@ +/** + * editorTourSteps — shape coverage. The step-by-step editor behavior + * (prepare() opening the right panels) is exercised indirectly through the + * store slices it calls; this test locks the tour's structural contract: + * exact ids, in order, unique, and every step has real copy. + */ +import { describe, expect, it } from 'bun:test' +import { editorTourSteps } from './editorTourSteps' + +const EXPECTED_IDS = ['welcome', 'explorer', 'new-page', 'modules', 'properties', 'framework', 'publish'] + +describe('editorTourSteps', () => { + it('has the expected step ids, in order', () => { + expect(editorTourSteps.map((step) => step.id)).toEqual(EXPECTED_IDS) + }) + + it('has unique step ids', () => { + const ids = editorTourSteps.map((step) => step.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('every step has a non-empty title and body', () => { + for (const step of editorTourSteps) { + expect(step.title.length).toBeGreaterThan(0) + expect(step.body.length).toBeGreaterThan(0) + } + }) +}) diff --git a/src/admin/pages/site/tour/editorTourSteps.ts b/src/admin/pages/site/tour/editorTourSteps.ts new file mode 100644 index 000000000..4d305fd39 --- /dev/null +++ b/src/admin/pages/site/tour/editorTourSteps.ts @@ -0,0 +1,102 @@ +/** + * editorTourSteps — the Site editor's first-run guided tour. + * + * Seven `TourStepDef`s driven by the generic `tourStore` / `TourOverlay` + * (`@admin/shared/tour`). Each step's `prepare()` puts the editor into the + * state its anchor needs (open the right left-sidebar panel, dock the + * Properties panel, etc.) before `TourOverlay` waits for the anchor to + * appear — see `useEditorTour.ts` for when the tour starts. + * + * `properties` is the one step whose anchor needs more than a panel-mode + * flip: `[data-testid="properties-panel"]` only renders when the panel is + * docked, NOT collapsed, AND something is selected (node, selector class, + * or a selector multi-select — see `selectRightSidebarExpanded` in + * `@site/store/store` and the early-return in `PropertiesPanel.tsx`). A + * fresh site editor session usually has nothing selected, so this step + * also selects the active page's root node when the selection is empty — + * selecting a node clears `propertiesPanel.collapsed` for free (see + * `applySelection` in `selectionSlice.ts`). + */ +import type { TourStepDef } from '@admin/shared/tour' +import { selectActivePage, useEditorStore } from '@site/store/store' + +function openExplorerSiteTab() { + const store = useEditorStore.getState() + store.setLeftSidebarPanel('explorer') + store.setExplorerPanelTab('site') +} + +function dockPropertiesPanelWithSelection() { + const store = useEditorStore.getState() + if (store.propertiesPanelMode !== 'docked') store.setPropertiesPanelMode('docked') + + const hasSelection = + store.selectedNodeId !== null || + store.selectedSelectorClassId !== null || + store.selectedSelectorClassIds.length > 0 + if (hasSelection) return + + // Nothing selected yet — select the active page's root node so the + // docked panel actually renders (see module doc comment above). + const activePage = selectActivePage(store) + if (activePage) store.selectNode(activePage.rootNodeId) +} + +function openFrameworkPanel() { + useEditorStore.getState().setLeftSidebarPanel('framework') +} + +export const editorTourSteps: TourStepDef[] = [ + { + id: 'welcome', + anchor: null, + title: 'Welcome to the site editor', + body: 'This is where you build your site. This one-minute tour shows you where everything lives — replay it anytime from the command palette (Cmd-K).', + }, + { + id: 'explorer', + anchor: 'site-explorer-panel', + title: 'Pages, templates and components', + body: 'The Explorer’s Site tab lists every page in your site. Your site starts with a Home page — open any page to edit it on the canvas.', + side: 'right', + prepare: openExplorerSiteTab, + }, + { + id: 'new-page', + anchor: 'site-explorer-new-page', + title: 'Create a new page', + body: 'New pages start here — blank, from a starter layout, or imported from HTML.', + side: 'right', + prepare: openExplorerSiteTab, + }, + { + id: 'modules', + anchor: 'canvas-notch', + title: 'Add content with modules', + body: 'The + button inserts modules — text, images, buttons, containers and more. Undo and redo live here too.', + side: 'bottom', + }, + { + id: 'properties', + anchor: 'properties-panel', + title: 'Style the selected element', + body: 'Everything about the selected element — layout, spacing, typography, attributes — is edited in the Properties panel.', + side: 'left', + prepare: dockPropertiesPanelWithSelection, + }, + { + id: 'framework', + anchor: 'framework-panel', + title: 'Your design variables', + body: 'The Framework panel holds your site-wide design tokens — Colors, Type and Space — as CSS :root variables. Change them once, they update everywhere.', + side: 'right', + prepare: openFrameworkPanel, + }, + { + id: 'publish', + anchor: 'toolbar-publish-btn', + title: 'Publish when ready', + body: 'Your edits stay drafts until you publish. That’s the tour — replay it anytime with Cmd-K → “Take the editor tour”.', + side: 'bottom', + }, +] diff --git a/src/admin/pages/site/tour/useEditorTour.ts b/src/admin/pages/site/tour/useEditorTour.ts new file mode 100644 index 000000000..567b92fed --- /dev/null +++ b/src/admin/pages/site/tour/useEditorTour.ts @@ -0,0 +1,62 @@ +/** + * useEditorTour — auto-start + outcome persistence for the Site editor's + * first-run guided tour. + * + * `startEditorTour()` is the imperative entry point: it hands `editorTourSteps` + * to the generic `tourStore` along with `persistOutcome` as the `onEnd` + * callback, so completing or dismissing the tour always saves the + * `editor-tour` user preference. Callers: `useEditorTour`'s auto-start + * effect below, and the `site.startTour` pending-action consumer in + * `SitePage.tsx` (replay from the command palette). + * + * `useEditorTour()` mounts once in the Site editor body and auto-starts the + * tour the first time a user opens the editor — i.e. only when the + * `editor-tour` preference has never been set (`null`). A preference fetch + * failure is treated as "already seen": we warn and skip rather than risk + * re-showing the tour (or blocking the editor) on every load of a broken + * install. + */ +import { useEffect } from 'react' +import { getUserPreference, setUserPreference } from '@core/persistence/userPreferences' +import { useTourStore, type TourOutcome } from '@admin/shared/tour' +import { editorTourSteps } from './editorTourSteps' + +function persistOutcome(outcome: TourOutcome) { + setUserPreference('editor-tour', { status: outcome }).catch((err) => { + console.error('[editor-tour] failed to save tour state:', err) + }) +} + +/** Starts the editor tour from the top — used for both auto-start and replay. */ +export function startEditorTour() { + useTourStore.getState().start(editorTourSteps, persistOutcome) +} + +/** + * Auto-starts the editor tour on first visit. Mount once in the Site + * editor body (never outside the site workspace — the tour's anchors and + * `prepare()` steps are Site-editor-only). + */ +export function useEditorTour() { + useEffect(() => { + let cancelled = false + + getUserPreference('editor-tour') + .then((pref) => { + if (cancelled) return + // Never seen AND no tour already running (e.g. a queued + // `site.startTour` replay that raced this fetch). + if (pref === null && useTourStore.getState().steps === null) { + startEditorTour() + } + }) + .catch((err) => { + // Treat as already-seen: never block or spam an erroring install. + console.warn('[editor-tour] preference read failed, skipping auto-start:', err) + }) + + return () => { + cancelled = true + } + }, []) +} diff --git a/src/admin/spotlight/pendingAction.ts b/src/admin/spotlight/pendingAction.ts index 1be5905a4..08c8ee821 100644 --- a/src/admin/spotlight/pendingAction.ts +++ b/src/admin/spotlight/pendingAction.ts @@ -43,6 +43,7 @@ const PENDING_ACTION_TYPES = [ 'media.upload', 'media.newFolder', 'plugins.install', + 'site.startTour', ] as const type PendingActionType = (typeof PENDING_ACTION_TYPES)[number] From c28dd164cbf2fb692031433340a5fd89dc36f156 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sat, 15 Aug 2026 18:21:15 -0400 Subject: [PATCH 07/21] fix(editor): guard tour pending action against auto-start race --- src/admin/pages/site/SitePage.tsx | 9 +- .../pages/site/tour/useEditorTour.test.ts | 149 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/admin/pages/site/tour/useEditorTour.test.ts diff --git a/src/admin/pages/site/SitePage.tsx b/src/admin/pages/site/SitePage.tsx index d749351d8..a17051fbc 100644 --- a/src/admin/pages/site/SitePage.tsx +++ b/src/admin/pages/site/SitePage.tsx @@ -3,6 +3,7 @@ import { AdminCanvasLayout } from '@admin/layouts/AdminCanvasLayout' import { consumePendingAction } from '@admin/spotlight/pendingAction' import { useEditorStore } from '@site/store/store' import { useMcpWorkspaceBridge } from '@admin/ai/useMcpWorkspaceBridge' +import { useTourStore } from '@admin/shared/tour' import { startEditorTour } from './tour/useEditorTour' import { executeAgentTool } from './agent' @@ -53,7 +54,13 @@ export function SitePage() { const startTour = consumePendingAction('site.startTour') if (startTour) { - startEditorTour() + // Guard against the auto-start race: a fresh user's editor-tour + // preference fetch (useEditorTour) can resolve and auto-start the + // tour before this pending action fires. Only (re)start here when + // no tour is already running, so we don't reset it back to step 1 + // and drop the running tour's onEnd. The pending action is still + // consumed either way — a tour ending up started satisfies it. + if (useTourStore.getState().steps === null) startEditorTour() return true } diff --git a/src/admin/pages/site/tour/useEditorTour.test.ts b/src/admin/pages/site/tour/useEditorTour.test.ts new file mode 100644 index 000000000..d664d8f87 --- /dev/null +++ b/src/admin/pages/site/tour/useEditorTour.test.ts @@ -0,0 +1,149 @@ +/** + * useEditorTour — coverage for the auto-start guard, the auto-start-vs- + * already-running race guard, and outcome persistence. Colocated with the + * hook per `src/admin/` convention (see OnboardingPanel.test.tsx). + * + * `getUserPreference` / `setUserPreference` are mocked via `mock.module` + * (see McpTab.test.tsx / pluginScheduler.test.ts) — the plain named + * function exports of `@core/persistence/userPreferences` aren't spy-able + * as object methods the way `cmsAdapter`'s class-instance methods are. + */ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import type { TourStepDef } from '@admin/shared/tour' +import { useTourStore } from '@admin/shared/tour' +import type { EditorTourPreference } from '@core/persistence/userPreferences' + +const getUserPreferenceMock = mock(async (): Promise => null) +const setUserPreferenceMock = mock(async (_key: string, value: unknown) => value) + +mock.module('@core/persistence/userPreferences', () => ({ + getUserPreference: getUserPreferenceMock, + setUserPreference: setUserPreferenceMock, +})) + +const { startEditorTour, useEditorTour } = await import('./useEditorTour') +const { editorTourSteps } = await import('./editorTourSteps') + +/** Flushes the microtask queue so the effect's promise chain settles. */ +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +afterEach(() => { + cleanup() + useTourStore.setState({ steps: null, stepIndex: 0, onEnd: null }) + getUserPreferenceMock.mockReset() + getUserPreferenceMock.mockImplementation(async () => null) + setUserPreferenceMock.mockReset() + setUserPreferenceMock.mockImplementation(async (_key: string, value: unknown) => value) +}) + +describe('useEditorTour', () => { + it('starts the tour with the 7 editor steps when the preference resolves null and nothing is running', async () => { + getUserPreferenceMock.mockImplementation(async () => null) + + renderHook(() => useEditorTour()) + + await waitFor(() => expect(useTourStore.getState().steps).toBe(editorTourSteps)) + expect(useTourStore.getState().steps).toHaveLength(7) + expect(useTourStore.getState().stepIndex).toBe(0) + }) + + it('does not restart a tour that is already running, even when the preference resolves null', async () => { + getUserPreferenceMock.mockImplementation(async () => null) + + const runningSteps: TourStepDef[] = [{ id: 'other', anchor: null, title: 'T', body: 'B' }] + const onEnd = mock() + useTourStore.setState({ steps: runningSteps, stepIndex: 1, onEnd }) + + renderHook(() => useEditorTour()) + + await waitFor(() => expect(getUserPreferenceMock).toHaveBeenCalledTimes(1)) + await flush() + + expect(useTourStore.getState().steps).toBe(runningSteps) + expect(useTourStore.getState().stepIndex).toBe(1) + expect(useTourStore.getState().onEnd).toBe(onEnd) + }) + + it('does not auto-start when the preference resolves already-completed', async () => { + getUserPreferenceMock.mockImplementation(async () => ({ status: 'completed' })) + + renderHook(() => useEditorTour()) + + await waitFor(() => expect(getUserPreferenceMock).toHaveBeenCalledTimes(1)) + await flush() + + expect(useTourStore.getState().steps).toBeNull() + }) + + it('warns with the [editor-tour] prefix and skips auto-start when the preference read rejects', async () => { + const warnSpy = mock(() => {}) + const originalWarn = console.warn + console.warn = warnSpy as typeof console.warn + + const readError = new Error('network down') + getUserPreferenceMock.mockImplementation(async () => { + throw readError + }) + + try { + renderHook(() => useEditorTour()) + + await waitFor(() => expect(warnSpy).toHaveBeenCalled()) + expect(warnSpy.mock.calls[0]?.[0]).toBe( + '[editor-tour] preference read failed, skipping auto-start:', + ) + expect(warnSpy.mock.calls[0]?.[1]).toBe(readError) + expect(useTourStore.getState().steps).toBeNull() + } finally { + console.warn = originalWarn + } + }) + + it('persists {status: "completed"} via setUserPreference on tour completion', async () => { + act(() => { + startEditorTour() + }) + expect(useTourStore.getState().steps).toBe(editorTourSteps) + + act(() => { + useTourStore.getState().complete() + }) + + await waitFor(() => expect(setUserPreferenceMock).toHaveBeenCalledTimes(1)) + expect(setUserPreferenceMock).toHaveBeenCalledWith('editor-tour', { status: 'completed' }) + }) + + it('logs console.error and does not throw when the completion PUT rejects', async () => { + const errorSpy = mock(() => {}) + const originalError = console.error + console.error = errorSpy as typeof console.error + + const putError = new Error('PUT failed') + setUserPreferenceMock.mockImplementation(async () => { + throw putError + }) + + try { + act(() => { + startEditorTour() + }) + + expect(() => { + act(() => { + useTourStore.getState().complete() + }) + }).not.toThrow() + + await waitFor(() => expect(errorSpy).toHaveBeenCalled()) + expect(errorSpy.mock.calls[0]?.[0]).toBe('[editor-tour] failed to save tour state:') + expect(errorSpy.mock.calls[0]?.[1]).toBe(putError) + } finally { + console.error = originalError + } + }) +}) From 0ae5f66c36f77a9f92d7ce872252af073cfc6df8 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sat, 15 Aug 2026 18:23:48 -0400 Subject: [PATCH 08/21] feat(spotlight): 'Take the editor tour' command --- src/admin/spotlight/builtinCommands.ts | 2 ++ src/admin/spotlight/commands/tour.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/admin/spotlight/commands/tour.ts diff --git a/src/admin/spotlight/builtinCommands.ts b/src/admin/spotlight/builtinCommands.ts index 0a3b756ea..185cdbec2 100644 --- a/src/admin/spotlight/builtinCommands.ts +++ b/src/admin/spotlight/builtinCommands.ts @@ -24,6 +24,7 @@ import { getLayersCommands } from './commands/layers' import { getPanelsCommands } from './commands/panels' import { getSettingsCommands } from './commands/settings' import { getHelpCommands } from './commands/help' +import { getTourCommands } from './commands/tour' import { getPagesCommands } from './commands/pages' import { getBreakpointsCommands } from './commands/breakpoints' import { getContentCommands } from './commands/content' @@ -86,6 +87,7 @@ export function getAllCommands(): Command[] { ...getSiteExportCommands(), ...getAiAssistantCommands(), ...getHelpCommands(), + ...getTourCommands(), ] } return [...CACHED_STATIC_COMMANDS, ...getPluginsCommands()] diff --git a/src/admin/spotlight/commands/tour.ts b/src/admin/spotlight/commands/tour.ts new file mode 100644 index 000000000..525ea44d9 --- /dev/null +++ b/src/admin/spotlight/commands/tour.ts @@ -0,0 +1,43 @@ +/** + * Tour commands — replay the Site editor's guided onboarding tour. + * + * - Take the editor tour → available everywhere. When the user is already + * on the site workspace, `startEditorTour()` runs directly. When they're + * on a different workspace (Content, Account, …) we queue a pending + * action and navigate — SitePage's pending-action consumer starts the + * tour on mount once the site has loaded. + */ + +import type { Command } from '../types' +import { queuePendingAction } from '../pendingAction' + +export function getTourCommands(): Command[] { + return [ + // ── Take the editor tour ───────────────────────────────────────────────── + { + id: 'help.editorTour', + title: 'Take the editor tour', + subtitle: 'Replay the guided tour of the site editor', + group: 'help', + iconName: 'sparkles-solid', + keywords: ['tour', 'help', 'onboarding', 'guide', 'walkthrough', 'editor'], + workspaces: ['any'], + capability: 'site.read', + run: async (ctx) => { + if (ctx.workspace === 'site') { + try { + const { startEditorTour } = await import('@site/tour/useEditorTour') + startEditorTour() + return + } catch (err) { + console.error('[spotlight] startEditorTour failed:', err) + } + } + + // Cross-workspace: queue + navigate. SitePage executes on mount. + queuePendingAction('site.startTour') + ctx.navigate('/admin/site') + }, + }, + ] +} From 66ed8e97ee73c00580624f6af14cf73b87b579ec Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sat, 15 Aug 2026 18:27:27 -0400 Subject: [PATCH 09/21] feat(dashboard): replace plugin onboarding step with editor tour step --- docs/e2e/feature-matrix.md | 2 +- docs/e2e/feature-validation.tsv | 2 +- docs/features/dashboard.md | 4 +- .../components/OnboardingPanel.test.tsx | 44 ++++++++++++++++++- .../dashboard/components/OnboardingPanel.tsx | 27 +++++++----- .../dashboard/hooks/useOnboardingState.ts | 20 +++++---- 6 files changed, 74 insertions(+), 25 deletions(-) diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index f1c3cfb6e..da9c6b686 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -97,7 +97,7 @@ DASH-001 note: `dashboard.e2e.ts` verifies the default owner dashboard route, Ov DASH-002 note: `dashboard.e2e.ts` verifies customize mode, the Block library, adding the built-in AI usage widget, server-backed `dashboard-layout` preference persistence, reload restoration, grid drag move, right-edge resize, drag-to-library removal, final reload absence, and 390px customize/library containment. DEF-20260623-DASH002-01 fixed invalid nested buttons in Block library live previews by rendering preview widget chrome in edit mode. -DASH-003 note: `dashboard.e2e.ts` runs in a dedicated post-setup, pre-persona project and verifies onboarding progress from genuinely clean E2E state (1/5: identity complete; framework in progress; first page, plugin, and team not started), all five step labels/actions, the Settings modal action, workspace routes for New page/Browse plugins/Add members, 390px mobile containment, and server-backed dismiss persistence through `dashboard-layout`. +DASH-003 note: `dashboard.e2e.ts` runs in a dedicated post-setup, pre-persona project and verifies onboarding progress from genuinely clean E2E state (1/5: identity complete; framework in progress; tour, first page, and team not started), all five step labels/actions, the Settings modal action, workspace routes for New page/Add members and the tour step's `site.startTour` pending-action route to `/admin/site`, 390px mobile containment, and server-backed dismiss persistence through `dashboard-layout`. ## Capabilities And Access Control diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index d08bd416f..281a9e26d 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -24,7 +24,7 @@ ADMIN-005 Workspace layout, panels, toasts, and error recovery As an admin user, CAP-001 Workspace isolation and limited admin navigation As an owner or admin, I want limited users to reach only the admin workspaces their role grants so protected workspace data and actions stay hidden. Custom roles expose workspaces through capability checks in src/admin/access.ts. A user with site.read and media.read can reach Site, Media, and self-targeted Account; disallowed workspace links are hidden and direct disallowed admin routes redirect away without rendering protected screens or data. At 390px, the limited toolbar remains contained, the granted Media affordance and account trigger remain reachable, and denied Content/Users affordances remain absent. Users with no dashboard access fall through to the first accessible workspace; Account remains available to every authenticated user; role changes can leave stale client navigation until the current user refreshes; direct denied URLs must not flash protected content; background plugin work must not run without plugin workspace access; narrow toolbar must not reintroduce hidden denied workspaces or overflow. Workspace visibility is derived from canAccessWorkspace and firstAccessibleWorkspace; role capabilities are normalized by the Users role editor and enforced again by server route capability gates; denied direct navigation must not render protected headings or tables; mobile checks assert viewport size, document overflow, toolbar containment, granted Media link containment, and account trigger containment. src/admin/access.ts; src/admin/AuthenticatedAdmin.tsx; src/admin/pages/site/toolbar/Toolbar.module.css; tests/e2e/users.e2e.ts; src/__tests__/admin/capabilityAwareAdmin.test.tsx; server/auth/authz.ts The Playwright scenarios use disposable owner sessions to create View site + Browse media roles and users; Account reachability is code-defined self-service behavior; mobile assertions target the shared toolbar containment for restricted workspace sets. Happy: limited Site + Media user logs in and sees Media while staying out of Users/Content. Error: direct denied /admin/users navigation redirects away. Boundary: no dashboard capability falls through to Site. Invalid: stale or unknown workspace path must not expose protected UI. Permission: server APIs still require their own capabilities. Performance: no background plugin fetch/SSE for non-plugin roles. Mobile: limited user signs in at 390x844, sees Site and Media without denied Content/Users affordances, toolbar has no document-level horizontal overflow, Media and account controls remain contained, and Account menu opens with Account & security reachable. Focused CAP-001 desktop and mobile Playwright regressions passed 2026-06-23 0 None Verification: `bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts -g "CAP-001"` passed 3/3 including setup. The desktop scenario creates a custom View site + Browse media role, creates a limited user, signs in from a clean context, verifies Media is visible while Content and Users are absent, then opens `/admin/users` directly and verifies the route redirects away and the All Users heading is not rendered. The mobile scenario creates the same restricted persona, signs in at 390x844, verifies Site and Media are present while Content/Users are absent, verifies the toolbar and account trigger are viewport-contained without page-level horizontal overflow, opens Account menu, and verifies Account & security remains reachable. Related access rules live in `src/admin/access.ts`; Account remains self-targeted for every authenticated user by `canAccessWorkspace`. Run logs: docs/e2e/runs/2026-06-23-cap001-workspace-isolation.md; docs/e2e/runs/2026-06-23-cap001-mobile-limited-navigation.md. 2026-06-23 DASH-001 Dashboard widgets and metrics As an admin user, I want a dashboard with site metrics and quick status widgets so I can understand CMS state. /admin/dashboard renders the dashboard page with greeting/actions, onboarding facts, Overview header, Today/7d/30d/All range state, Customize/Add block controls, and the default nine first-party widgets: storage, pages, posts, media, status, activity, publish lineup, plugins, and domain. Dynamic widgets fetch their own dashboard endpoint and leave aria-busy once loaded; status and domain render static operational rows; the mobile Overview header stacks/wraps controls. Empty site and zero counts; per-widget API failure leaves that widget loading instead of crashing the dashboard; missing plugin widget definitions render skeleton placeholders; no saved layout preference falls back to the default layout; first-party range tabs are local state today; narrow viewports can overflow if header controls do not wrap. Dashboard workspace requires dashboard.read through admin access gates; widget endpoints require authenticated user or domain capability (media.read, plugins.read, audit.read); client responses are TypeBox-validated through apiRequest; dashboard layout preferences are schema-validated and recovered from invalid stored values. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/DashboardPage.module.css; src/admin/pages/dashboard/widgets/index.ts; src/admin/pages/dashboard/hooks/useDashboardStats.ts; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; server/handlers/cms/dashboard; tests/e2e/dashboard.e2e.ts The default E2E owner has dashboard, media, plugin, and audit capabilities; E2E storage label is SQLite; status/domain widget values are currently static placeholders; plugin-contributed widgets are outside the default clean-install grid. Happy: owner opens dashboard and sees all default first-party widgets with loaded data. Error: widget endpoint failure should not blank the page. Boundary: clean install with zero media/posts/plugins. Invalid: invalid saved layout falls back. Permission: limited users without widget capabilities do not receive protected widget data. Performance: widgets load progressively. Mobile: at 390px the Overview header, range tabs, Customize, and Add block controls fit without horizontal overflow. Focused DASH-001 Playwright regression passed 2026-06-23 after mobile header overflow fix 0 None DEF-20260623-DASH001-01 resolved: at 390px the Overview header kept title, range tabs, Customize, and Add block in one row; the Time range tablist right edge reached 451.5px in a 390px viewport. Root cause: DashboardPage.module.css gridHeader stayed row-oriented with a nowrap right control cluster. Fix: stack the dashboard header and wrap the right controls below 760px. Verification: bun run test:e2e -- --project=e2e tests/e2e/dashboard.e2e.ts -g "DASH-001". Run log: docs/e2e/runs/2026-06-23-dash001-dashboard-widgets.md. 2026-06-23 DASH-002 Dashboard customize grid As an admin user, I want to customize dashboard widgets so the dashboard fits my workflow. /admin/dashboard customize mode keeps the grid mounted, switches the header action to Done, widens grid gutters, exposes resize handles, shows the add tile, and opens the bottom Block library. The library shows only widgets not already on the dashboard; clicking AI usage appends it below existing widgets, debounced PUT saves the server-backed dashboard-layout user preference, reload restores it, dragging moves it to an empty cell, right-edge resize increases its span, dragging to the library pill removes it, and a final reload keeps it removed. At 390px the customize controls and Block library stay viewport-contained. Duplicate add is a no-op; explicit occupied drops are resolved down to the nearest empty row before commit; move to an occupied destination is rejected by the layout model; missing widget definitions reserve skeleton slots; all placed widgets show an Every block status; corrupted stored preferences log and keep the default layout; library height is clamped; block preview chrome must not create nested interactive elements. Layout constraints use MAX_COLS, MIN_COLS, MIN_ROWS, MAX_ROWS, snapToCell, hasOverlapAt, and TypeBox-validated dashboard-layout preferences; unknown preference keys are rejected; dashboard access requires dashboard.read; all preference responses are parsed through schema validation in the E2E test. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/components/DashboardGrid.tsx; src/admin/pages/dashboard/components/BlockLibrary.tsx; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; src/core/persistence/userPreferences.ts; src/ui/components/Widget/Widget.tsx; tests/e2e/dashboard.e2e.ts The clean E2E owner has the nine default dashboard widgets and the built-in AI usage widget registered but not placed; plugin-provided widgets are outside this clean-install regression; drag behaviour needs real browser coverage and uses pointer gestures. Happy: add AI usage from the Block library, verify saved preference payload, reload restored widget, drag move, resize, drag-to-library remove, and reload absence. Error: live previews must not emit nested-button console errors. Boundary: no available widgets after adding AI usage. Invalid: corrupted layout fallback remains lower-level/manual. Permission: dashboard workspace access required. Performance: debounced preference saves coalesce gestures. Mobile: at 390px customize controls, page width, library dialog, search, and add action stay contained. Focused DASH-002 Playwright regression passed 2026-06-23 after block-library nested-button fix 0 None DEF-20260623-DASH002-01 resolved: Block library live previews rendered widgets in view mode inside an Add widget button, so Widget added its own options button and React logged invalid nested button markup when AI usage appeared in the library. Root cause: LibraryItem used Render editing=false for preview chrome. Fix: render previews with editing=true so Widget emits a non-interactive drag handle inside the add/drag surface. Verification: bun run test:e2e -- --project=e2e tests/e2e/dashboard.e2e.ts -g "DASH-002"; run log docs/e2e/runs/2026-06-23-dash002-dashboard-customize.md. 2026-06-23 -DASH-003 Onboarding panel As a new admin, I want onboarding tasks that reflect setup progress so I know what to do next. /admin/dashboard defers onboarding rendering until setup facts load. When dashboard-layout onboardingDismissed is false, OnboardingPanel renders five tasks from useOnboardingState: identity (site name changed from Untitled Site or favicon), framework (site.settings.framework), first page (site.pages.length >= 2), plugin (installed plugin count), and team (users.length > 1). Clean E2E state shows 1 of 5 steps complete because only site identity is complete; framework is in progress and first page/plugin/team are not started. Task actions either open Settings or route to /admin/site, /admin/plugins, and /admin/users. Dismiss saves dashboard-layout with onboardingDismissed true and reload keeps the panel hidden. At 390px the panel and Add members action stay contained. Facts endpoints can fail individually and Promise.allSettled falls back to defaults instead of bricking the dashboard; all tasks may be complete; dismissed panel stays hidden; corrupted dashboard-layout preference falls back through layout recovery; later test-created pages must not pollute clean-install facts; target workspaces may still be access-gated by route capabilities. Client API responses are validated through cmsAdapter, listCmsPlugins, and listCmsUsers; dashboard-layout preference schema includes onboardingDismissed boolean; dashboard route requires dashboard.read; target routes enforce their workspace capabilities. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/components/OnboardingPanel.tsx; src/admin/pages/dashboard/hooks/useOnboardingState.ts; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; src/core/persistence/userPreferences.ts; tests/e2e/dashboard.e2e.ts Clean E2E site name is Automated E2E Site; setup seeds only Home, so firstPage remains not started; owner has settings, site, plugins, and users access; no plugins or extra team members exist in clean E2E state. Happy: verify 1/5 progress, five task labels/states/actions, Settings modal action, workspace routes, server-backed dismiss save, and reload-hidden persistence. Error: facts API soft failure is covered by implementation inspection; focused browser regression does not stub each failed endpoint. Boundary: dismissed panel remains absent after reload. Invalid: corrupted preference fallback remains lower-level/manual. Permission: target routes remain behind workspace gates. Performance: panel waits for facts before rendering. Mobile: at 390px the panel and Add members action stay contained. Dashboard preflight Playwright regression passed 2026-07-11 0 None Full-suite release gating exposed the stale fixture assumption: setup seeds only Home, so firstPage is not started and clean progress is 1/5. `dashboard-preflight` now runs all dashboard scenarios immediately after setup and before persona/plugin mutations; the focused 2026-07-11 run passed DASH-001, DASH-002, and DASH-003 before the dependent persona project. 2026-07-11 +DASH-003 Onboarding panel As a new admin, I want onboarding tasks that reflect setup progress so I know what to do next. /admin/dashboard defers onboarding rendering until setup facts load. When dashboard-layout onboardingDismissed is false, OnboardingPanel renders five tasks from useOnboardingState: identity (site name changed from Untitled Site or favicon), framework (site.settings.framework), tour (editor-tour user preference status === completed), first page (site.pages.length >= 2), and team (users.length > 1). Clean E2E state shows 1 of 5 steps complete because only site identity is complete; framework is in progress and tour/first page/team are not started. Task actions either open Settings, queue the site.startTour pending action and route to /admin/site, or route to /admin/site and /admin/users. Dismiss saves dashboard-layout with onboardingDismissed true and reload keeps the panel hidden. At 390px the panel and Add members action stay contained. Facts endpoints can fail individually and Promise.allSettled falls back to defaults instead of bricking the dashboard; all tasks may be complete; dismissed panel stays hidden; corrupted dashboard-layout preference falls back through layout recovery; later test-created pages must not pollute clean-install facts; target workspaces may still be access-gated by route capabilities. Client API responses are validated through cmsAdapter, getUserPreference, and listCmsUsers; dashboard-layout preference schema includes onboardingDismissed boolean; dashboard route requires dashboard.read; target routes enforce their workspace capabilities. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/components/OnboardingPanel.tsx; src/admin/pages/dashboard/hooks/useOnboardingState.ts; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; src/core/persistence/userPreferences.ts; tests/e2e/dashboard.e2e.ts Clean E2E site name is Automated E2E Site; setup seeds only Home, so firstPage remains not started; owner has settings, site, plugins, and users access; no plugins or extra team members exist in clean E2E state. Happy: verify 1/5 progress, five task labels/states/actions, Settings modal action, workspace routes, server-backed dismiss save, and reload-hidden persistence. Error: facts API soft failure is covered by implementation inspection; focused browser regression does not stub each failed endpoint. Boundary: dismissed panel remains absent after reload. Invalid: corrupted preference fallback remains lower-level/manual. Permission: target routes remain behind workspace gates. Performance: panel waits for facts before rendering. Mobile: at 390px the panel and Add members action stay contained. Dashboard preflight Playwright regression passed 2026-07-11 0 None Full-suite release gating exposed the stale fixture assumption: setup seeds only Home, so firstPage is not started and clean progress is 1/5. `dashboard-preflight` now runs all dashboard scenarios immediately after setup and before persona/plugin mutations; the focused 2026-07-11 run passed DASH-001, DASH-002, and DASH-003 before the dependent persona project. 2026-07-11 SPOT-001 Spotlight open/search/navigation As an admin user, I want a command palette so I can quickly navigate and run actions. Cmd/Ctrl+K opens Spotlight, focuses input, searches built-in and provider commands, Enter runs navigation/action, Esc closes and restores focus. No matches; repeated open; route changes while open; disabled workspace command; keyboard order. Commands are registered through commandRegistry/scopes; actions check capabilities; provider results validated with schemas. src/admin/spotlight; src/admin/spotlight/builtinCommands.ts; src/admin/spotlight/providerRunner.ts Browser keyboard events differ by OS; tests use platform-neutral shortcuts where possible. Happy: open, search, navigate. Error: provider failure shown/ignored. Boundary: empty query and no-match query. Invalid: unknown command id. Permission: inaccessible commands hidden/disabled. Performance: search responsive with providers. Mobile: keyboard/accessibility focus works. SPOT-010 async skeleton full command-palette regression passed 2026-06-22 after stale async provider state fix 0 None SPOT-010 promoted: hold the Content provider search request, verify the Content skeleton group is busy, release the request, and verify the Async Skeleton Probe result replaces the skeleton. Defect E2E-20260622-SPOT010-01 fixed: stale empty asyncResults from the palette's initial empty query suppressed the later skeleton for the same provider; `SET_QUERY` now clears asyncResults/loadingProviders. Prior promotions remain covered for SPOT-009 nested panel focus, SPOT-011 keyboard-only execution, SPOT-012 reduced motion, and SPOT-013 high contrast. Verification: TSV and diff guards passed; focused reducer red/green passed; focused SPOT-010 E2E passed; full command-palette E2E passed 14/14; bun run lint passed; bun run build passed; bun test passed 5496/5496. 2026-06-22 SPOT-002 Spotlight scoped commands and pending actions As an admin user, I want scoped palette flows for pages, layers, breakpoints, data, media, users, and plugins so actions happen in context. Selecting a scope pushes subcommands; some commands set pending actions consumed by destination workspace after navigation; editor commands mutate active editor state. Context missing selected node/page; destination loads slowly; pending action consumed once; stale pending action after failed nav. Scopes and pending actions are typed in spotlight files; command capabilities gate actions; destructive commands use confirmation flow. src/admin/spotlight/scopes; src/admin/spotlight/commands; src/admin/spotlight/pendingAction.ts; src/admin/pages/site/SitePage.tsx Some commands require hydrated editor store. Happy: create page/VC from palette. Error: missing context command disabled. Boundary: pending action survives nav load. Invalid: malformed args ignored. Permission: role cannot run disallowed command. Performance: provider lookup bounded. Mobile: palette remains usable. SPOT-007 selected-layer context-ranking regression and full command-palette E2E file passed 2026-06-22; broader pending-action exploratory pending 0 None SPOT-007 promoted in `command-palette.e2e.ts`: Duplicate layer is absent outside editor context, a newly inserted text layer exposes selected-layer controls, and Duplicate layer appears in the top five empty-palette options. Verification: focused selected-layer E2E, full `tests/e2e/command-palette.e2e.ts`, and `bun run lint` passed. 2026-06-22 SPOT-003 Spotlight recents and destructive confirmation As an admin user, I want recent commands and protection for destructive commands so speed does not compromise safety. Recent commands persist and appear on reopen; destructive commands require two Enter confirmations and timeout collapse after configured duration. Timer expiry; command disappears after state change; repeated Enter double-fire; recents dedupe. Recent store validates local storage; destructive command state owned in Spotlight state; no native confirm dialogs. src/admin/spotlight/recentStore.ts; src/admin/spotlight/state.ts; src/admin/spotlight/SpotlightResults.tsx Confirm timeout is now Playwright-covered; OS accessibility variants remain observational. Happy: recent command appears. Error: destructive command cancel/timeout no mutation. Boundary: confirmation at exactly timeout. Invalid: corrupted recent store. Permission: destructive command still capability gated. Performance: recents load instantly. Mobile: confirm row visible. SPOT-005 destructive confirm-timeout Playwright regression and full command-palette E2E file passed 2026-06-22; broader context-ranking and accessibility exploratory pending 0 None SPOT-005 promoted in `command-palette.e2e.ts`: first Enter arms Delete current page, timeout clears the prompt, and the throwaway page remains. Verification: focused timeout E2E, full `tests/e2e/command-palette.e2e.ts`, and `bun run lint` passed. 2026-06-22 diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index 029e3bf3f..296df00ae 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -265,11 +265,11 @@ Each CMS hook fetches on mount through `useAsyncResource` + `apiRequest`, valida - [ ] Set site identity - [ ] Choose Core Framework import +- [ ] Tour the editor - [ ] Create your first page -- [ ] Install a plugin - [ ] Invite your team -State lives in `useOnboardingState(...)`. It reads the current site, installed plugins, and users concurrently. The seed Home page does not satisfy "Create your first page"; that step flips done when the site has at least two pages. Framework import defaults to `active` until the user picks a framework mode. +State lives in `useOnboardingState(...)`. It reads the current site, the user's `editor-tour` preference, and the users list concurrently. The seed Home page does not satisfy "Create your first page"; that step flips done when the site has at least two pages. Framework import defaults to `active` until the user picks a framework mode. The tour step flips done only when the `editor-tour` preference status is `'completed'` — a dismissed tour leaves the step `'todo'` rather than nagging, since dismissing isn't the same as finishing it. Its CTA queues the `site.startTour` pending action (`@admin/spotlight/pendingAction`) and navigates to `/admin/site`, where `SitePage` consumes it and starts the guided tour. The panel is dismissible per-user and persisted with the dashboard layout preference (`dashboard-layout`). `useDashboardLayout.restoreOnboarding()` flips the same preference flag back to visible. diff --git a/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx b/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx index c19d8ecdd..969f29666 100644 --- a/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx +++ b/src/admin/pages/dashboard/components/OnboardingPanel.test.tsx @@ -11,13 +11,20 @@ */ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter } from '@admin/lib/routing' +import { MemoryRouter, useLocation } from '@admin/lib/routing' import { cmsAdapter } from '@core/persistence/cms' import type { SiteDocument } from '@core/page-tree' import { CMS_SITE_RELOAD_EVENT } from '@admin/state/adminEvents' +import { peekPendingAction } from '@admin/spotlight/pendingAction' import { OnboardingPanel } from './OnboardingPanel' import type { OnboardingFacts } from '../hooks/useOnboardingState' +/** Renders the current in-memory route so tests can assert on navigation. */ +function LocationProbe() { + const { pathname } = useLocation() + return {pathname} +} + afterEach(cleanup) function fakeSite(): SiteDocument { @@ -40,10 +47,11 @@ function fakeSite(): SiteDocument { } const FACTS: OnboardingFacts = { + loading: false, identity: 'active', framework: 'active', + tour: 'active', firstPage: 'active', - plugin: 'active', team: 'active', } @@ -90,3 +98,35 @@ describe('OnboardingPanel framework import', () => { } }) }) + +describe('OnboardingPanel tour step', () => { + afterEach(() => { + globalThis.sessionStorage?.clear() + }) + + it('renders the tour step and no longer offers the removed plugin step', () => { + render( + + {}} onFrameworkImported={() => {}} /> + , + ) + + expect(screen.getByText('Tour the editor')).toBeTruthy() + expect(screen.queryByText('Install a plugin')).toBeNull() + expect(screen.queryByRole('button', { name: /browse plugins/i })).toBeNull() + }) + + it('queues the site.startTour pending action and navigates to the Site workspace', () => { + render( + + {}} onFrameworkImported={() => {}} /> + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^start tour$/i })) + + expect(peekPendingAction('site.startTour')).not.toBeNull() + expect(screen.getByTestId('location-probe').textContent).toBe('/admin/site') + }) +}) diff --git a/src/admin/pages/dashboard/components/OnboardingPanel.tsx b/src/admin/pages/dashboard/components/OnboardingPanel.tsx index dc935b84d..58996ad15 100644 --- a/src/admin/pages/dashboard/components/OnboardingPanel.tsx +++ b/src/admin/pages/dashboard/components/OnboardingPanel.tsx @@ -31,12 +31,13 @@ import { CheckIcon } from 'pixel-art-icons/icons/check' import { ChevronRightIcon } from 'pixel-art-icons/icons/chevron-right' import { FileTextSolidIcon } from 'pixel-art-icons/icons/file-text-solid' import { ImageSolidIcon } from 'pixel-art-icons/icons/image-solid' -import { PackageSolidIcon } from 'pixel-art-icons/icons/package-solid' +import { TargetSolidIcon } from 'pixel-art-icons/icons/target-solid' import { UsersSolidIcon } from 'pixel-art-icons/icons/users-solid' import { CodeIcon } from 'pixel-art-icons/icons/code' import { useAdminNavigate } from '@admin/lib/useAdminNavigate' import { useAdminUi } from '@admin/state/adminUi' import { requestCmsSiteReload } from '@admin/state/adminEvents' +import { queuePendingAction } from '@admin/spotlight/pendingAction' import { Button } from '@ui/components/Button' import type { PixelArtIconComponent } from '@core/dashboard' import type { OnboardingFacts, OnboardingStepState } from '../hooks/useOnboardingState' @@ -51,7 +52,7 @@ import { reconcileFrameworkClasses } from '@site/store/slices/site/framework/rec import styles from './OnboardingPanel.module.css' interface StepDef { - id: keyof Pick + id: keyof Pick title: string desc: string cta: string @@ -60,6 +61,7 @@ interface StepDef { | { kind: 'navigate'; to: string } | { kind: 'settings-modal' } | { kind: 'framework-import' } + | { kind: 'start-tour' } } const STEPS: readonly StepDef[] = [ @@ -81,6 +83,15 @@ const STEPS: readonly StepDef[] = [ icon: CodeIcon, action: { kind: 'framework-import' }, }, + { + id: 'tour', + title: 'Tour the editor', + desc: + 'A one-minute guided walk through the editor — pages, modules, properties and your design variables.', + cta: 'Start tour', + icon: TargetSolidIcon, + action: { kind: 'start-tour' }, + }, { id: 'firstPage', title: 'Create your first page', @@ -90,15 +101,6 @@ const STEPS: readonly StepDef[] = [ icon: FileTextSolidIcon, action: { kind: 'navigate', to: '/admin/site' }, }, - { - id: 'plugin', - title: 'Install a plugin', - desc: - 'Add SEO, comments, image optimization or workflow extensions from the registry.', - cta: 'Browse plugins', - icon: PackageSolidIcon, - action: { kind: 'navigate', to: '/admin/plugins' }, - }, { id: 'team', title: 'Invite your team', @@ -183,6 +185,9 @@ export function OnboardingPanel({ facts, onDismiss, onFrameworkImported }: Onboa navigate(step.action.to) } else if (step.action.kind === 'framework-import') { setFrameworkImportOpen(true) + } else if (step.action.kind === 'start-tour') { + queuePendingAction('site.startTour') + navigate('/admin/site') } else { openSettings('general') } diff --git a/src/admin/pages/dashboard/hooks/useOnboardingState.ts b/src/admin/pages/dashboard/hooks/useOnboardingState.ts index d0aa3f4b7..00c6f0140 100644 --- a/src/admin/pages/dashboard/hooks/useOnboardingState.ts +++ b/src/admin/pages/dashboard/hooks/useOnboardingState.ts @@ -7,9 +7,13 @@ * • Framework import — derived from `site.settings.framework` being * populated. Defaults to `'active'` so the user is nudged to make a * deliberate decision; once they pick a mode the step flips to done. + * • Tour — done when the `editor-tour` user preference status is + * `'completed'`. A `'dismissed'` tour (or never started) stays + * `'todo'` — deliberate: dismissing the tour isn't the same as + * learning the editor, so the step never nags, it just stays + * unchecked until the user actually finishes it. * • First page — done when ≥ 2 pages exist (the seed Home page * doesn't count). - * • First plugin — done when any plugin is installed. * • Team — done when more than the owner is in the users table. * * Reads concurrently in `Promise.all` so the dashboard renders the @@ -19,8 +23,8 @@ */ import { useAsyncResource } from '@admin/lib/useAsyncResource' import { cmsAdapter } from '@core/persistence/cms' -import { listCmsPlugins } from '@core/persistence/cmsPlugins' import { listCmsUsers } from '@core/persistence/cmsUsers' +import { getUserPreference } from '@core/persistence/userPreferences' export type OnboardingStepState = 'done' | 'active' | 'todo' @@ -28,8 +32,8 @@ export interface OnboardingFacts { loading: boolean identity: OnboardingStepState framework: OnboardingStepState + tour: OnboardingStepState firstPage: OnboardingStepState - plugin: OnboardingStepState team: OnboardingStepState } @@ -37,8 +41,8 @@ const INITIAL: OnboardingFacts = { loading: true, identity: 'todo', framework: 'active', + tour: 'todo', firstPage: 'todo', - plugin: 'todo', team: 'todo', } @@ -52,15 +56,15 @@ export function useOnboardingState(): OnboardingStateResult { // `Promise.allSettled` never rejects — each individual failure soft-fails to // an empty/undefined value so a broken endpoint doesn't brick the dashboard. const { data, refresh } = useAsyncResource(async () => { - const [siteResult, pluginsResult, usersResult] = await Promise.allSettled([ + const [siteResult, usersResult, tourResult] = await Promise.allSettled([ cmsAdapter.loadSite('default'), - listCmsPlugins(), listCmsUsers(), + getUserPreference('editor-tour'), ]) const site = siteResult.status === 'fulfilled' ? siteResult.value?.site : undefined - const plugins = pluginsResult.status === 'fulfilled' ? pluginsResult.value.plugins : [] const users = usersResult.status === 'fulfilled' ? usersResult.value : [] + const tourPref = tourResult.status === 'fulfilled' ? tourResult.value : null const hasIdentity = Boolean(site && site.name && site.name !== 'Untitled Site') const hasFavicon = Boolean(site?.settings?.faviconUrl) @@ -71,8 +75,8 @@ export function useOnboardingState(): OnboardingStateResult { loading: false, identity: hasIdentity || hasFavicon ? 'done' : 'active', framework: hasFramework ? 'done' : 'active', + tour: tourPref?.status === 'completed' ? 'done' : 'todo', firstPage: pageCount >= 2 ? 'done' : 'todo', - plugin: plugins.length > 0 ? 'done' : 'todo', team: users.length > 1 ? 'done' : 'todo', } }, []) From 6a11270048ccbef714d3673ae384fc296f35423f Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sun, 16 Aug 2026 15:52:08 -0400 Subject: [PATCH 10/21] fix(spotlight): classify dashboard, AI and plugin-page routes for workspace-scoped commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspaceFromPathname() only recognized content/data/media/plugins/ users/account and defaulted everything else — including /admin/dashboard and /admin/ai — to 'site'. That let site-only commands (layers, panels, framework, preview, ...) leak into workspaces with no open editor, and made cross-workspace commands like "Take the editor tour" think they were already on the Site workspace instead of queuing their navigate. --- src/admin/spotlight/SpotlightRoot.tsx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/admin/spotlight/SpotlightRoot.tsx b/src/admin/spotlight/SpotlightRoot.tsx index 0f33544fa..d642fb05c 100644 --- a/src/admin/spotlight/SpotlightRoot.tsx +++ b/src/admin/spotlight/SpotlightRoot.tsx @@ -64,14 +64,32 @@ const LazySpotlight = lazy(() => // ─── Workspace detection ────────────────────────────────────────────────────── +/** + * Mirrors the `section` each route hands `` in `router.tsx`. Falls + * back to `'dashboard'` (not `'site'`) for anything unrecognized, matching the + * router's own catch-all (`/admin/*` → `/admin/dashboard`) — a stray/unknown + * path is the admin home, not the Site editor. Getting this right matters + * beyond navigation labels: `filterCommands` gates every `site`-scoped + * command (layers, panels, framework, preview, …) on `ctx.workspace === 'site'`, + * so misclassifying, say, `/admin/dashboard` as `'site'` would both surface + * those commands where there's no open editor to run them against AND make a + * command like "Take the editor tour" think it's already on the Site + * workspace instead of queuing its cross-workspace navigate. + */ function workspaceFromPathname(pathname: string): AdminWorkspace { if (pathname.startsWith('/admin/content')) return 'content' if (pathname.startsWith('/admin/data')) return 'data' if (pathname.startsWith('/admin/media')) return 'media' + // Plugin *pages* (`/admin/plugins/:pluginId/:pageId`) are a distinct + // workspace from the plugin manager list (`/admin/plugins`) — check the + // more specific path first. + if (/^\/admin\/plugins\/[^/]+\/[^/]+/.test(pathname)) return 'pluginPage' if (pathname.startsWith('/admin/plugins')) return 'plugins' if (pathname.startsWith('/admin/users')) return 'users' + if (pathname.startsWith('/admin/ai')) return 'ai' if (pathname.startsWith('/admin/account')) return 'account' - return 'site' + if (pathname.startsWith('/admin/site')) return 'site' + return 'dashboard' } // ─── Editor context snapshot type ──────────────────────────────────────────── From 54424e6057a3d170e41b6c7c6eb567d475655173 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sun, 16 Aug 2026 15:52:10 -0400 Subject: [PATCH 11/21] test(e2e): first-run editor tour flow --- tests/e2e/account-persona.setup.ts | 18 +++- tests/e2e/dashboard.e2e.ts | 12 ++- tests/e2e/editor-tour.e2e.ts | 150 +++++++++++++++++++++++++++++ tests/e2e/helpers/index.ts | 1 + tests/e2e/helpers/preferences.ts | 51 ++++++++++ 5 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/editor-tour.e2e.ts create mode 100644 tests/e2e/helpers/preferences.ts diff --git a/tests/e2e/account-persona.setup.ts b/tests/e2e/account-persona.setup.ts index eaef76095..49e4958a8 100644 --- a/tests/e2e/account-persona.setup.ts +++ b/tests/e2e/account-persona.setup.ts @@ -1,6 +1,13 @@ import { expect, test as setup } from '@playwright/test' import { ACCOUNT_PERSONA } from './helpers/constants' -import { completeStepUp, expectLoggedIn, login, loginAs, logout } from './helpers' +import { + completeStepUp, + expectLoggedIn, + login, + loginAs, + logout, + seedEditorTourPreference, +} from './helpers' /** * Create the identity used by destructive account self-management tests. @@ -8,9 +15,17 @@ import { completeStepUp, expectLoggedIn, login, loginAs, logout } from './helper * Those tests rotate credentials, toggle MFA, and revoke other sessions. Keeping * them on a separate Admin prevents them from invalidating the owner session * serialized by `auth.setup.ts` and consumed by the rest of the suite. + * + * This project also seeds the `editor-tour` preference (as `'completed'`) for + * both shared identities it touches — the owner and the persona it creates. + * It runs after `dashboard-preflight` (whose onboarding-checklist assertions + * need the owner's tour preference untouched) and before the main `e2e` + * project (whose dozens of specs open the Site editor expecting a clean + * canvas, not a first-run tour bubble). See `helpers/preferences.ts`. */ setup('create the account-management persona', async ({ page }) => { await login(page) + await seedEditorTourPreference(page, 'completed') await page.goto('/admin/users') await expect(page.getByRole('table', { name: 'Users' })).toBeVisible() @@ -33,4 +48,5 @@ setup('create the account-management persona', async ({ page }) => { await logout(page) await loginAs(page, ACCOUNT_PERSONA.email, ACCOUNT_PERSONA.password) await expectLoggedIn(page) + await seedEditorTourPreference(page, 'completed') }) diff --git a/tests/e2e/dashboard.e2e.ts b/tests/e2e/dashboard.e2e.ts index 1f98bfae7..a39fec645 100644 --- a/tests/e2e/dashboard.e2e.ts +++ b/tests/e2e/dashboard.e2e.ts @@ -212,8 +212,8 @@ test.describe('dashboard', () => { await expectOnboardingStep(panel, 'Set site identity', 'Completed', 'Open settings') await expectOnboardingStep(panel, 'Choose Core Framework import', 'In progress', 'Import') + await expectOnboardingStep(panel, 'Tour the editor', 'Not started', 'Start tour') await expectOnboardingStep(panel, 'Create your first page', 'Not started', 'New page') - await expectOnboardingStep(panel, 'Install a plugin', 'Not started', 'Browse plugins') await expectOnboardingStep(panel, 'Invite your team', 'Not started', 'Add members') await test.step('step actions route to the expected workspaces', async () => { @@ -227,8 +227,14 @@ test.describe('dashboard', () => { await page.goto('/admin/dashboard') await expect(panel).toBeVisible({ timeout: 20_000 }) - await panel.getByRole('button', { name: 'Browse plugins' }).click() - await expect(page).toHaveURL(/\/admin\/plugins$/) + // "Start tour" both navigates to the Site editor AND starts the tour + // there (queued as a pending action the editor consumes on mount) — + // unlike the other steps' CTAs, which only navigate. + await panel.getByRole('button', { name: 'Start tour' }).click() + await expect(page).toHaveURL(/\/admin\/site$/) + await expect( + page.getByRole('dialog').filter({ hasText: /Step \d+ of 7/ }), + ).toBeVisible({ timeout: 20_000 }) await page.goto('/admin/dashboard') await expect(panel).toBeVisible({ timeout: 20_000 }) diff --git a/tests/e2e/editor-tour.e2e.ts b/tests/e2e/editor-tour.e2e.ts new file mode 100644 index 000000000..717657390 --- /dev/null +++ b/tests/e2e/editor-tour.e2e.ts @@ -0,0 +1,150 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { + clearEditorTourPreference, + expectEditorReady, + getEditorTourPreferenceStatus, + seedEditorTourPreference, + type EditorTourOutcome, +} from './helpers' + +/** + * TOUR-001 through TOUR-004 — the Site editor's first-run guided tour. + * + * A fresh user (no `editor-tour` preference set) auto-starts the tour on + * opening `/admin/site`; stepping through all seven steps or skipping early + * both persist an outcome via `PUT /admin/api/cms/me/preferences/editor-tour` + * so the tour never reappears on reload; and it can be replayed anytime from + * the command palette regardless of that persisted outcome. + * + * Every test starts by explicitly seeding or clearing the shared owner's + * `editor-tour` preference (rather than relying on ambient state) because the + * suite otherwise keeps this identity permanently past the tour — see + * `helpers/preferences.ts` and `account-persona.setup.ts`. + */ +const OPEN_PALETTE_KEY = process.platform === 'darwin' ? 'Meta+k' : 'Control+k' + +test.describe('editor tour', () => { + test('auto-starts for a first-run user and completes after all seven steps (TOUR-001)', async ({ + page, + }) => { + await clearEditorTourPreference(page) + + await test.step('opening the Site editor auto-starts the tour at step 1', async () => { + await page.goto('/admin/site') + await expect(tourDialog(page)).toBeVisible({ timeout: 20_000 }) + await expect(tourDialog(page)).toContainText('Welcome to the site editor') + await expect(tourDialog(page)).toContainText('Step 1 of 7') + await expect(tourDialog(page).getByRole('button', { name: 'Skip tour' })).toBeVisible() + await expect(tourDialog(page).getByRole('button', { name: 'Next' })).toBeVisible() + await expect(tourDialog(page).getByRole('button', { name: 'Back' })).toHaveCount(0) + }) + + await test.step('stepping through all seven steps finishes and persists "completed"', async () => { + for (let step = 1; step <= 7; step++) { + await expect(tourDialog(page)).toContainText(`Step ${step} of 7`) + const isLastStep = step === 7 + const button = tourDialog(page).getByRole('button', { + name: isLastStep ? 'Finish' : 'Next', + }) + if (isLastStep) { + const saved = waitForTourPreferenceSave(page, 'completed') + await button.click() + await saved + } else { + await button.click() + } + } + await expect(tourDialog(page)).toBeHidden() + }) + + await test.step('reloading shows the canvas with no tour', async () => { + await page.reload() + await expectEditorReady(page) + await expect(tourDialog(page)).toHaveCount(0) + }) + }) + + test('skipping persists "dismissed" and does not auto-start again (TOUR-002)', async ({ + page, + }) => { + await clearEditorTourPreference(page) + + await page.goto('/admin/site') + await expect(tourDialog(page)).toBeVisible({ timeout: 20_000 }) + + const saved = waitForTourPreferenceSave(page, 'dismissed') + await tourDialog(page).getByRole('button', { name: 'Skip tour' }).click() + await saved + await expect(tourDialog(page)).toBeHidden() + expect(await getEditorTourPreferenceStatus(page)).toBe('dismissed') + + await page.reload() + await expectEditorReady(page) + await expect(tourDialog(page)).toHaveCount(0) + }) + + test('replays from the command palette regardless of a persisted outcome (TOUR-003)', async ({ + page, + }) => { + // A completed (non-null) preference proves the palette command starts + // the tour on its own — it isn't just observing an auto-start. + await seedEditorTourPreference(page, 'completed') + + await page.goto('/admin/dashboard') + await openCommandPalette(page) + await commandPaletteInput(page).fill('tour') + await page.getByRole('option', { name: 'Take the editor tour' }).click() + + // The palette queues a pending action and navigates cross-workspace — + // SitePage starts the tour once the editor store hydrates. + await expect(page).toHaveURL(/\/admin\/site$/) + await expect(tourDialog(page)).toBeVisible({ timeout: 20_000 }) + await expect(tourDialog(page)).toContainText('Step 1 of 7') + + // Leave the session tour-free again for whichever spec runs next. + const saved = waitForTourPreferenceSave(page, 'dismissed') + await tourDialog(page).getByRole('button', { name: 'Skip tour' }).click() + await saved + }) +}) + +/** The tour's coach-mark bubble — a `role="dialog"` carrying "Step N of 7". */ +function tourDialog(page: Page): Locator { + return page.getByRole('dialog').filter({ hasText: /Step \d+ of 7/ }) +} + +function commandPalette(page: Page): Locator { + return page.getByRole('dialog', { name: 'Command palette' }) +} + +function commandPaletteInput(page: Page): Locator { + return page.getByRole('combobox', { name: 'Search commands' }) +} + +async function openCommandPalette(page: Page): Promise { + await expect(page.getByTestId('account-menu-trigger')).toBeVisible() + const dialog = commandPalette(page) + await expect(async () => { + await page.keyboard.press(OPEN_PALETTE_KEY) + await expect(dialog).toBeVisible({ timeout: 1_000 }) + }).toPass() +} + +/** + * Waits for the `editor-tour` preference PUT that fires when the tour ends + * (`persistOutcome` in `useEditorTour.ts`), and asserts it saved the expected + * outcome. Started before the UI action that triggers it. + */ +function waitForTourPreferenceSave(page: Page, outcome: EditorTourOutcome): Promise { + return page + .waitForResponse( + (response) => + response.url().includes('/admin/api/cms/me/preferences/editor-tour') && + response.request().method() === 'PUT' && + response.status() === 200, + ) + .then(async (response) => { + const body = (await response.json()) as { value: { status: EditorTourOutcome } } + expect(body.value.status).toBe(outcome) + }) +} diff --git a/tests/e2e/helpers/index.ts b/tests/e2e/helpers/index.ts index 70a4c9eb7..356f93ecb 100644 --- a/tests/e2e/helpers/index.ts +++ b/tests/e2e/helpers/index.ts @@ -1,4 +1,5 @@ export * from './constants' export * from './auth' export * from './editor' +export * from './preferences' export * from './public' diff --git a/tests/e2e/helpers/preferences.ts b/tests/e2e/helpers/preferences.ts new file mode 100644 index 000000000..85df38c6e --- /dev/null +++ b/tests/e2e/helpers/preferences.ts @@ -0,0 +1,51 @@ +import { expect, type Page } from '@playwright/test' + +/** + * `editor-tour` user-preference helpers. + * + * The Site editor auto-starts its first-run guided tour for any user whose + * `editor-tour` preference has never been set (`useEditorTour` in + * `src/admin/pages/site/tour/useEditorTour.ts`). Every shared test identity + * (the owner, the account-management persona) is used across dozens of specs + * that open the Site editor expecting a clean canvas — an auto-started tour + * bubble would block those. The setup projects seed the preference as + * `'completed'` for those identities (see `account-persona.setup.ts`) so the + * rest of the suite stays tour-free; `editor-tour.e2e.ts` uses + * `clearEditorTourPreference` to put the shared owner session back into a + * genuinely first-run state for its own assertions. + */ + +const EDITOR_TOUR_PREFERENCE_PATH = '/admin/api/cms/me/preferences/editor-tour' + +export type EditorTourOutcome = 'completed' | 'dismissed' + +/** Seed the current session's `editor-tour` preference, bypassing the UI. */ +export async function seedEditorTourPreference( + page: Page, + status: EditorTourOutcome, +): Promise { + const response = await page.request.put(EDITOR_TOUR_PREFERENCE_PATH, { + data: { value: { status } }, + }) + expect(response.ok(), `seed editor-tour preference: ${response.status()}`).toBe(true) +} + +/** + * Reset the current session's `editor-tour` preference to "never set" — the + * state a genuinely first-run user is in. The Site editor auto-starts the + * tour only when this preference has never been written. + */ +export async function clearEditorTourPreference(page: Page): Promise { + const response = await page.request.delete(EDITOR_TOUR_PREFERENCE_PATH) + expect(response.ok(), `clear editor-tour preference: ${response.status()}`).toBe(true) +} + +/** Read the current session's persisted `editor-tour` status, or `null` if never set. */ +export async function getEditorTourPreferenceStatus( + page: Page, +): Promise { + const response = await page.request.get(EDITOR_TOUR_PREFERENCE_PATH) + expect(response.ok(), `get editor-tour preference: ${response.status()}`).toBe(true) + const body = (await response.json()) as { value: { status: EditorTourOutcome } | null } + return body.value?.status ?? null +} From 08c0c3af1cff90b48d7a4634b78f2c25fc17c0c9 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sun, 16 Aug 2026 16:08:23 -0400 Subject: [PATCH 12/21] test(e2e): seed editor-tour preference for ad-hoc capability/AI personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capabilities.e2e.ts and ai.e2e.ts create their own throwaway personas and drive them into the Site editor via a local openReadableSiteEditor helper. Those personas never touch the central seeding in account-persona.setup.ts, so they were genuine first-run users — the tour's fixed inset-0 backdrop would intercept their subsequent clicks. Seed 'completed' at the top of each file's openReadableSiteEditor, before the first /admin/site visit. --- tests/e2e/ai.e2e.ts | 11 +++++++++++ tests/e2e/capabilities.e2e.ts | 11 +++++++++++ tests/e2e/editor-tour.e2e.ts | 5 ++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/e2e/ai.e2e.ts b/tests/e2e/ai.e2e.ts index a62f17a54..f06e22fce 100644 --- a/tests/e2e/ai.e2e.ts +++ b/tests/e2e/ai.e2e.ts @@ -5,6 +5,7 @@ import { completeStepUp, login, loginAs, + seedEditorTourPreference, } from './helpers' import { openSiteEditor } from './helpers/editor' @@ -197,7 +198,17 @@ async function createUser( await completeStepUp(page) } +/** + * Opens the Site editor for whichever persona `page` is authenticated as. + * These personas are freshly created just for this spec and never touch the + * central seeding in `account-persona.setup.ts`, so without this they'd be + * genuine first-run users — the tour's fixed `inset-0` backdrop would then + * intercept every click these capability tests make afterward. Seeding + * before the (possibly first) navigation to `/admin/site` avoids the race + * with `useEditorTour`'s own preference fetch. + */ async function openReadableSiteEditor(page: Page): Promise { + await seedEditorTourPreference(page, 'completed') if (!(await page.getByTestId('canvas-root').isVisible({ timeout: 1_000 }).catch(() => false))) { await page.goto('/admin/site') } diff --git a/tests/e2e/capabilities.e2e.ts b/tests/e2e/capabilities.e2e.ts index 0398af3d6..c53ce38f6 100644 --- a/tests/e2e/capabilities.e2e.ts +++ b/tests/e2e/capabilities.e2e.ts @@ -10,6 +10,7 @@ import { openLayersPanel, openSitePanel, openSiteEditor, + seedEditorTourPreference, setPropValue, canvasFrame, insertNotchModule, @@ -1121,7 +1122,17 @@ async function openNamedPage(page: Page, name: string): Promise { await expect(item).toHaveAttribute('aria-selected', 'true') } +/** + * Opens the Site editor for whichever persona `page` is authenticated as. + * These personas are freshly created just for this spec and never touch the + * central seeding in `account-persona.setup.ts`, so without this they'd be + * genuine first-run users — the tour's fixed `inset-0` backdrop would then + * intercept every click these capability tests make afterward. Seeding + * before the (possibly first) navigation to `/admin/site` avoids the race + * with `useEditorTour`'s own preference fetch. + */ async function openReadableSiteEditor(page: Page): Promise { + await seedEditorTourPreference(page, 'completed') if (!(await page.getByTestId('canvas-root').isVisible({ timeout: 1_000 }).catch(() => false))) { await page.goto('/admin/site') } diff --git a/tests/e2e/editor-tour.e2e.ts b/tests/e2e/editor-tour.e2e.ts index 717657390..5c49cfce3 100644 --- a/tests/e2e/editor-tour.e2e.ts +++ b/tests/e2e/editor-tour.e2e.ts @@ -101,7 +101,10 @@ test.describe('editor tour', () => { await expect(tourDialog(page)).toBeVisible({ timeout: 20_000 }) await expect(tourDialog(page)).toContainText('Step 1 of 7') - // Leave the session tour-free again for whichever spec runs next. + // Leave the session tour-free again for whichever spec runs next. Ending + // on 'dismissed' rather than 'completed' (account-persona.setup.ts's + // steady state for the owner) is fine — auto-start only checks for a + // never-set preference, and either non-null outcome blocks it. const saved = waitForTourPreferenceSave(page, 'dismissed') await tourDialog(page).getByRole('button', { name: 'Skip tour' }).click() await saved From e13f6c8dfd120e12ded20d6fb8fb2307168a9986 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sun, 16 Aug 2026 16:08:27 -0400 Subject: [PATCH 13/21] test(spotlight): cover workspaceFromPathname's route classification Moves workspaceFromPathname out of SpotlightRoot.tsx into its own module (same reason spotlightContext.ts is split out: react-refresh/only-export- components wants .tsx files to only export components) so it's a plain, directly testable export. Adds one case per AdminWorkspace plus the dashboard catch-all, covering the branches the prior fix commit touched. --- src/admin/spotlight/SpotlightRoot.tsx | 32 +--------- .../__tests__/workspaceFromPathname.test.ts | 60 +++++++++++++++++++ src/admin/spotlight/workspaceFromPathname.ts | 34 +++++++++++ 3 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 src/admin/spotlight/__tests__/workspaceFromPathname.test.ts create mode 100644 src/admin/spotlight/workspaceFromPathname.ts diff --git a/src/admin/spotlight/SpotlightRoot.tsx b/src/admin/spotlight/SpotlightRoot.tsx index d642fb05c..ac23a21bf 100644 --- a/src/admin/spotlight/SpotlightRoot.tsx +++ b/src/admin/spotlight/SpotlightRoot.tsx @@ -45,7 +45,7 @@ import { useCurrentAdminUser } from '@admin/sessionContext' import { useStepUp } from '@admin/shared/StepUp' import { spotlightReducer, initialState } from './state' import { ProviderRunner } from './providerRunner' -import type { AdminWorkspace } from '@admin/workspace' +import { workspaceFromPathname } from './workspaceFromPathname' import type { Command, CommandContext, CommandRunContext } from './types' import type { SpotlightControls } from './spotlightControls' import { recordRecentCommand } from './recentStore' @@ -62,36 +62,6 @@ const LazySpotlight = lazy(() => import('./Spotlight').then((m) => ({ default: m.Spotlight })), ) -// ─── Workspace detection ────────────────────────────────────────────────────── - -/** - * Mirrors the `section` each route hands `` in `router.tsx`. Falls - * back to `'dashboard'` (not `'site'`) for anything unrecognized, matching the - * router's own catch-all (`/admin/*` → `/admin/dashboard`) — a stray/unknown - * path is the admin home, not the Site editor. Getting this right matters - * beyond navigation labels: `filterCommands` gates every `site`-scoped - * command (layers, panels, framework, preview, …) on `ctx.workspace === 'site'`, - * so misclassifying, say, `/admin/dashboard` as `'site'` would both surface - * those commands where there's no open editor to run them against AND make a - * command like "Take the editor tour" think it's already on the Site - * workspace instead of queuing its cross-workspace navigate. - */ -function workspaceFromPathname(pathname: string): AdminWorkspace { - if (pathname.startsWith('/admin/content')) return 'content' - if (pathname.startsWith('/admin/data')) return 'data' - if (pathname.startsWith('/admin/media')) return 'media' - // Plugin *pages* (`/admin/plugins/:pluginId/:pageId`) are a distinct - // workspace from the plugin manager list (`/admin/plugins`) — check the - // more specific path first. - if (/^\/admin\/plugins\/[^/]+\/[^/]+/.test(pathname)) return 'pluginPage' - if (pathname.startsWith('/admin/plugins')) return 'plugins' - if (pathname.startsWith('/admin/users')) return 'users' - if (pathname.startsWith('/admin/ai')) return 'ai' - if (pathname.startsWith('/admin/account')) return 'account' - if (pathname.startsWith('/admin/site')) return 'site' - return 'dashboard' -} - // ─── Editor context snapshot type ──────────────────────────────────────────── type EditorCtxSnapshot = NonNullable diff --git a/src/admin/spotlight/__tests__/workspaceFromPathname.test.ts b/src/admin/spotlight/__tests__/workspaceFromPathname.test.ts new file mode 100644 index 000000000..fcd72a90c --- /dev/null +++ b/src/admin/spotlight/__tests__/workspaceFromPathname.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'bun:test' +import { workspaceFromPathname } from '../workspaceFromPathname' + +/** + * workspaceFromPathname — one case per `AdminWorkspace`, mirroring the routes + * `router.tsx` hands to ``. The catch-all case + * matters most: it used to default to `'site'`, which both leaked + * site-only commands into every unrecognized route and made cross-workspace + * commands like "Take the editor tour" think they were already on the Site + * workspace (see the `fix(spotlight)` commit this test accompanies). + */ +describe('workspaceFromPathname', () => { + it('classifies the Site editor', () => { + expect(workspaceFromPathname('/admin/site')).toBe('site') + expect(workspaceFromPathname('/admin/site/some/nested/path')).toBe('site') + }) + + it('classifies Content', () => { + expect(workspaceFromPathname('/admin/content')).toBe('content') + }) + + it('classifies Data', () => { + expect(workspaceFromPathname('/admin/data')).toBe('data') + }) + + it('classifies Media', () => { + expect(workspaceFromPathname('/admin/media')).toBe('media') + }) + + it('classifies the plugin manager list as Plugins', () => { + expect(workspaceFromPathname('/admin/plugins')).toBe('plugins') + }) + + it('classifies an installed plugin page as pluginPage, not Plugins', () => { + expect(workspaceFromPathname('/admin/plugins/acme.widget/settings')).toBe('pluginPage') + }) + + it('classifies Users', () => { + expect(workspaceFromPathname('/admin/users')).toBe('users') + }) + + it('classifies AI', () => { + expect(workspaceFromPathname('/admin/ai')).toBe('ai') + expect(workspaceFromPathname('/admin/ai/oauth/authorize')).toBe('ai') + }) + + it('classifies Account', () => { + expect(workspaceFromPathname('/admin/account')).toBe('account') + }) + + it('classifies Dashboard', () => { + expect(workspaceFromPathname('/admin/dashboard')).toBe('dashboard') + }) + + it('falls back to Dashboard for any unrecognized path, matching the router catch-all', () => { + expect(workspaceFromPathname('/admin')).toBe('dashboard') + expect(workspaceFromPathname('/')).toBe('dashboard') + expect(workspaceFromPathname('/admin/not-a-real-workspace')).toBe('dashboard') + }) +}) diff --git a/src/admin/spotlight/workspaceFromPathname.ts b/src/admin/spotlight/workspaceFromPathname.ts new file mode 100644 index 000000000..1a1537124 --- /dev/null +++ b/src/admin/spotlight/workspaceFromPathname.ts @@ -0,0 +1,34 @@ +import type { AdminWorkspace } from '@admin/workspace' + +/** + * Mirrors the `section` each route hands `` in `router.tsx`. Falls + * back to `'dashboard'` (not `'site'`) for anything unrecognized, matching the + * router's own catch-all (`/admin/*` → `/admin/dashboard`) — a stray/unknown + * path is the admin home, not the Site editor. Getting this right matters + * beyond navigation labels: `filterCommands` gates every `site`-scoped + * command (layers, panels, framework, preview, …) on `ctx.workspace === 'site'`, + * so misclassifying, say, `/admin/dashboard` as `'site'` would both surface + * those commands where there's no open editor to run them against AND make a + * command like "Take the editor tour" think it's already on the Site + * workspace instead of queuing its cross-workspace navigate. + * + * Lives in its own `.ts` file (not inline in `SpotlightRoot.tsx`) so it's a + * plain importable/testable export — same reason `spotlightContext.ts` is + * split out: `react-refresh/only-export-components` wants `.tsx` files to + * only export components. + */ +export function workspaceFromPathname(pathname: string): AdminWorkspace { + if (pathname.startsWith('/admin/content')) return 'content' + if (pathname.startsWith('/admin/data')) return 'data' + if (pathname.startsWith('/admin/media')) return 'media' + // Plugin *pages* (`/admin/plugins/:pluginId/:pageId`) are a distinct + // workspace from the plugin manager list (`/admin/plugins`) — check the + // more specific path first. + if (/^\/admin\/plugins\/[^/]+\/[^/]+/.test(pathname)) return 'pluginPage' + if (pathname.startsWith('/admin/plugins')) return 'plugins' + if (pathname.startsWith('/admin/users')) return 'users' + if (pathname.startsWith('/admin/ai')) return 'ai' + if (pathname.startsWith('/admin/account')) return 'account' + if (pathname.startsWith('/admin/site')) return 'site' + return 'dashboard' +} From 03b12f5daff673bbba336c63da791d49ea0b230c Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sun, 16 Aug 2026 16:17:18 -0400 Subject: [PATCH 14/21] docs(editor): document the guided editor tour --- CHANGELOG.md | 5 + docs/README.md | 2 + docs/e2e/feature-matrix.md | 10 ++ docs/editor.md | 7 ++ docs/features/editor-tour.md | 181 +++++++++++++++++++++++++++++ docs/features/spotlight.md | 1 + docs/reference/design-tokens.md | 11 +- docs/reference/persistence-keys.md | 2 + 8 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 docs/features/editor-tour.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bfb7858b..56c1eca01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This project is pre-1.0. Breaking changes may appear in minor or patch releases ## Unreleased +### Editor and onboarding + +- Added a first-run guided tour to the Site editor — a seven-step coach-mark walkthrough of the Explorer, module insertion, Properties panel, Framework panel, and Publish, built on a new reusable tour engine. The tour auto-starts once per user, persists its outcome server-side, and replays anytime from the command palette or the dashboard onboarding checklist. +- Replaced the dashboard onboarding checklist's plugin step with a "Tour the editor" step. + ## 0.0.16 - 2026-08-11 ### Media and integrations diff --git a/docs/README.md b/docs/README.md index 2b1606817..4ad39e86e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ docs/ │ ├── data-workspace.md ← Data workspace: table schema + field management UI │ ├── dashboard.md ← Dashboard workspace + widget registry │ ├── spotlight.md ← Cmd+K command palette +│ ├── editor-tour.md ← first-run guided tour engine + Site editor steps │ ├── agent.md ← AI agent integration │ ├── templates.md ← entry templates + dynamic bindings │ ├── loops.md ← base.loop + loop sources @@ -147,6 +148,7 @@ Three categories, three voices: | [features/modules.md](features/modules.md) | Module engine, defining first-party blocks | | [features/dashboard.md](features/dashboard.md) | Dashboard workspace, widgets, grid, customize mode | | [features/spotlight.md](features/spotlight.md) | Cmd+K command palette | +| [features/editor-tour.md](features/editor-tour.md) | First-run guided tour engine + the Site editor's 7-step tour | | [features/agent.md](features/agent.md) | AI agent integration and provider-agnostic runtime | | [features/mcp-connectors.md](features/mcp-connectors.md) | Instatic as an MCP server — external AI clients drive the CMS over MCP | | [features/templates.md](features/templates.md) | Entry templates + dynamic bindings + token interpolation | diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index da9c6b686..c0d93430a 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -165,6 +165,16 @@ SITE-018 note: `visual-builder.e2e.ts` first publishes a disposable post, create SITE-019 note: `visual-builder.e2e.ts` saves a styled Container subtree as a layout, verifies blank and duplicate-name validation, confirms the Layouts category remains reachable at 390px, inserts the saved layout into another page with its captured class styling, renames and deletes the saved layout from the inserter manage menu, saves/reloads, publishes, and verifies anonymous public output. DEF-20260623-SITE019-001 fixed stale node selection on `addPage`; DEF-20260623-SITE019-002 fixed count-only mobile category buttons; DEF-20260623-SITE019-003 fixed the saved-layout manage menu z-index under the spotlight inserter. +## Editor Onboarding Tour + +| ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For | +|---|---:|:---:|---|---|---|---|---|---| +| TOUR-001 | P2 | ✅ | First run | Auto-start the guided tour on a first visit and complete it | Fresh user, `editor-tour` preference cleared | Open `/admin/site`, step through all 7 steps to Finish | Tour auto-starts at step 1, advances through all 7 steps, and persists a `completed` outcome that survives reload | tour reappears after reload, stuck step, anchor never resolves | +| TOUR-002 | P2 | ✅ | Skip | Skip the tour early | Fresh user, `editor-tour` preference cleared | Open `/admin/site`, click Skip tour | Tour closes immediately and persists a `dismissed` outcome that survives reload | tour reappears after reload, skip not persisted | +| TOUR-003 | P2 | ✅ | Replay | Replay the tour from the command palette regardless of a persisted outcome | `editor-tour` preference already `completed` | `/admin/dashboard`, ⌘K → "Take the editor tour" | Palette navigates to `/admin/site` and restarts the tour at step 1 even though the outcome was already `completed` | replay blocked by persisted outcome, wrong starting step, no cross-workspace navigation | + +Editor tour note: `editor-tour.e2e.ts` covers the Site editor's first-run guided tour (see [docs/features/editor-tour.md](../features/editor-tour.md)). TOUR-001 clears the `editor-tour` preference, opens `/admin/site`, verifies the tour auto-starts on step 1 of 7, steps through Next on every step to Finish on step 7, and confirms the `PUT /admin/api/cms/me/preferences/editor-tour` save reports `completed` and the tour stays gone after reload. TOUR-002 clears the preference, opens the editor, clicks "Skip tour", confirms the save reports `dismissed`, and confirms the tour stays gone after reload. TOUR-003 seeds a `completed` preference first (proving the palette command starts the tour on its own, not just observing an auto-start), opens the palette from the Dashboard, runs "Take the editor tour", and verifies the cross-workspace navigate to `/admin/site` and the tour restarting at step 1; it ends by skipping again so the shared E2E identity is left tour-free for the next spec. Preference seeding/clearing helpers live in `tests/e2e/helpers/preferences.ts`; the shared owner persona's steady state and ad-hoc capability/AI persona seeding are handled in `account-persona.setup.ts`. + ## Site Runtime And Code | ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For | diff --git a/docs/editor.md b/docs/editor.md index 0f2177e20..c2bba88ef 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -653,6 +653,12 @@ Saved layouts persist as rows in the `layouts` system table (`savedLayoutFromRow --- +## Guided tour + +The Site editor auto-starts a seven-step coach-mark tour the first time a user opens `/admin/site` (Explorer → new page → modules → Properties → Framework → Publish). It's built on a generic, reusable tour engine (`src/admin/shared/tour/`) with the Site-specific steps and auto-start/persistence logic layered on top (`src/admin/pages/site/tour/`), mounted in `AdminCanvasEditorBody`. Outcome (`completed`/`dismissed`) persists as the `editor-tour` server preference so it never reappears uninvited, and it replays anytime via the Spotlight command "Take the editor tour" or the Dashboard onboarding checklist. Full design: [docs/features/editor-tour.md](features/editor-tour.md). + +--- + ## Spotlight (Cmd+K palette) `src/admin/spotlight/` is the command palette. Mounted by `` in `AuthenticatedAdmin`, so it's available from every workspace. @@ -730,6 +736,7 @@ See [docs/features/plugin-system.md](features/plugin-system.md) for the plugin S - [docs/server.md](server.md) — what the server does - [docs/design.md](design.md) — visual design system - [docs/features/plugin-system.md](features/plugin-system.md) — plugin SDK and lifecycle +- [docs/features/editor-tour.md](features/editor-tour.md) — the first-run guided tour engine + Site editor steps - [docs/reference/page-tree.md](reference/page-tree.md) — the `NodeTree` primitive - [docs/reference/ui-primitives.md](reference/ui-primitives.md) — UI primitive usage - Source-of-truth files: diff --git a/docs/features/editor-tour.md b/docs/features/editor-tour.md new file mode 100644 index 000000000..2607ff2c3 --- /dev/null +++ b/docs/features/editor-tour.md @@ -0,0 +1,181 @@ +# Editor Tour + +The Site editor's first-run guided tour: a seven-step coach-mark walkthrough built on a generic, reusable tour engine. + +The tour introduces a new user to the Site editor — Explorer, module insertion, Properties panel, the Framework panel, and Publish — the first time they open `/admin/site`. It auto-starts once, persists its outcome server-side so it never reappears uninvited, and can be replayed anytime from the command palette or the dashboard onboarding checklist. + +--- + +## TL;DR + +- **Generic engine**: `src/admin/shared/tour/` — `TourStepDef`, `useTourStore` (Zustand), `TourOverlay` (SVG-spotlight coach-mark renderer). Knows nothing about the Site editor; any future tour reuses it. +- **Editor-specific tour**: `src/admin/pages/site/tour/` — `editorTourSteps.ts` (the 7 steps) + `useEditorTour.ts` (auto-start + persistence). +- Mounted once, in `AdminCanvasEditorBody` (`src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx`): `useEditorTour()` for auto-start, `` to render it. +- Persistence: the `editor-tour` server-side user preference (`{ status: 'completed' | 'dismissed' }`, `null` = never seen). Auto-start fires only when the preference is `null`. See [docs/reference/persistence-keys.md](../reference/persistence-keys.md). +- Replay paths: the Spotlight command **"Take the editor tour"** (`help.editorTour`, any workspace) and the Dashboard onboarding checklist's **"Tour the editor"** step — both work regardless of a persisted outcome. +- Styling: `--tour-z-index: 9500` (`src/styles/globals.css`) — above every editor surface, below toasts/tooltips. See [docs/reference/design-tokens.md](../reference/design-tokens.md). + +--- + +## Architecture + +Two layers, split by reusability: + +```text +src/admin/shared/tour/ ← generic engine (editor-agnostic) +├── types.ts TourStepDef, TourOutcome +├── tourStore.ts useTourStore — Zustand: steps, stepIndex, onEnd, start/next/back/dismiss/complete +├── TourOverlay.tsx coach-mark renderer (SVG spotlight cutout + positioned bubble) +├── TourOverlay.module.css backdrop, spotlight cutout, bubble chrome (--tour-z-index) +└── index.ts barrel: useTourStore, TourOverlay, TourOutcome, TourStepDef + +src/admin/pages/site/tour/ ← Site editor's tour +├── editorTourSteps.ts the 7-step TourStepDef[] with editor-specific prepare() callbacks +└── useEditorTour.ts startEditorTour() + useEditorTour() auto-start hook +``` + +`tourStore.ts` imports nothing from `@site/*`, persistence, or spotlight/overlay modules — a future tour (e.g. a Content-workspace or Plugins-workspace walkthrough) reuses `useTourStore` and `TourOverlay` by supplying its own `TourStepDef[]` and `onEnd` callback. Everything that knows about the Site editor's own panels, anchors, and preference key lives in `src/admin/pages/site/tour/`. + +### `TourStepDef` + +```ts +interface TourStepDef { + id: string // stable id (analytics, debugging) + anchor: string | null // data-testid of the target element; null = centered step + title: string + body: string + side?: FloatingSide + align?: FloatingAlign + prepare?: () => void | Promise // put the editor into the state the anchor needs +} +``` + +`prepare()` runs before `TourOverlay` waits for the anchor — open the panel that contains it, dock the Properties panel and pick a selection, switch the left-sidebar tab, etc. A step whose anchor never appears (`prepare()` failed, or the element never renders) is soft-skipped: `TourOverlay` polls for up to two seconds, logs a warning, and calls `onNext()` rather than leaving the tour stuck. + +### `useTourStore` + +```ts +interface TourState { + steps: TourStepDef[] | null + stepIndex: number + onEnd: ((outcome: TourOutcome) => void) | null + start: (steps: TourStepDef[], onEnd: (outcome: TourOutcome) => void) => void + next: () => void // completes the tour on the last step + back: () => void + dismiss: () => void // ends with 'dismissed' + complete: () => void // ends with 'completed' +} +``` + +`steps === null` is the idle state — `TourOverlay` renders nothing. Ending the tour (falling off the last step via `next()`, or explicit `dismiss()`/`complete()`) clears the running state and fires `onEnd` exactly once with the outcome. + +### `TourOverlay` + +Portal-rendered to `document.body`. Three-component shape: + +- `TourOverlay` bails out before any hooks run when `steps === null` — no idle-render hook cost. +- `TourOverlayInner` owns subscriptions that live for the whole tour (current step index, the window-level Escape-to-dismiss listener). +- `TourStep`, remounted via `key={stepIndex}` on every step change, owns the per-step lifecycle: run `prepare()`, then locate the anchor (or go straight to a centered layout) via `waitForAnchor`, which polls `document.querySelector('[data-testid=""]')` once per animation frame. + +Anchored steps render an SVG mask cutting a rounded-rect "spotlight" hole around the anchor's `getBoundingClientRect()`, inflated by 6px; a `ResizeObserver` + scroll/resize listeners keep the cutout and bubble glued to the anchor as it moves. Centered steps (`anchor: null` — welcome/finish) render a flat scrim instead. The bubble itself (`role="dialog"`, `aria-modal="true"`, focused on mount) shows "Step N of M", title, body, and Skip/Back/Next actions; its position is computed by `computeFloatingPosition` (`@ui/lib/floatingPosition`) the same way tooltips are placed. + +--- + +## The Site editor's tour: 7 steps + +`editorTourSteps.ts` — each step's `anchor` is a `data-testid` already present on the target element: + +| # | id | anchor | prepare() | +|---|----|--------|-----------| +| 1 | `welcome` | *(centered)* | — | +| 2 | `explorer` | `site-explorer-panel` | opens the Explorer's Site tab | +| 3 | `new-page` | `site-explorer-new-page` | opens the Explorer's Site tab | +| 4 | `modules` | `canvas-notch` | — | +| 5 | `properties` | `properties-panel` | docks the Properties panel; selects the active page's root node if nothing is selected (the panel only renders docked + expanded + with a selection) | +| 6 | `framework` | `framework-panel` | opens the Framework left-sidebar panel | +| 7 | `publish` | `toolbar-publish-btn` | *(centered — finish)* | + +Step 5 (`properties`) is the one step whose anchor needs more than a panel-mode flip: `[data-testid="properties-panel"]` only renders when the panel is docked, not collapsed, **and** something is selected (a node, a selector class, or a selector multi-select — see `selectRightSidebarExpanded` in `@site/store/store` and the early-return in `PropertiesPanel.tsx`). A fresh session usually has no selection, so `dockPropertiesPanelWithSelection()` also selects the active page's root node when the selection is empty — `applySelection` (`selectionSlice.ts`) clears `propertiesPanel.collapsed` as a side effect, so the panel renders for free. + +The `site-explorer-new-page` testid is wired through `SiteExplorerTreeSection`'s `actionTestId` prop (`src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanelSections.tsx`). + +--- + +## Lifecycle — auto-start, replay, persistence + +```text +useEditorTour() mounts (AdminCanvasEditorBody, every Site editor session) + → GET /admin/api/cms/me/preferences/editor-tour + → null (never seen) AND no tour already running → startEditorTour() + → non-null, or fetch failed → do nothing (treated as "already seen") + +startEditorTour() + → useTourStore.getState().start(editorTourSteps, persistOutcome) + +Tour ends (Skip / Finish / falls off the last step) + → tourStore calls onEnd(outcome) + → persistOutcome(outcome): PUT /admin/api/cms/me/preferences/editor-tour + { status: 'completed' | 'dismissed' } +``` + +`startEditorTour()` (exported from `useEditorTour.ts`) is the single imperative entry point — both the auto-start effect and every replay path call it, so `persistOutcome` is always wired as the `onEnd` callback and a replay always re-persists an outcome on completion/dismissal. + +A preference-fetch failure is treated as "already seen" rather than retried or blocking the editor: an install with a broken preferences endpoint should not spam every session with an unstoppable tour. + +### Auto-start vs. replay race + +`SitePage.tsx`'s pending-action consumer (see "Replay paths" below) and `useEditorTour`'s auto-start effect both run on mount and can race for a fresh user who replays via a queued `site.startTour` action before the preference fetch resolves. The pending-action consumer guards against double-starting: it only calls `startEditorTour()` when `useTourStore.getState().steps === null` (no tour already running), so a genuine auto-start in flight isn't reset back to step 1 with its `onEnd` dropped. The pending action still counts as "consumed" either way — a tour ending up started (by either path) satisfies it. + +### Replay paths + +1. **Spotlight command** `help.editorTour` ("Take the editor tour", group `help`) — `src/admin/spotlight/commands/tour.ts`, available on every workspace (`workspaces: ['any']`), gated by `site.read`. + - On the `site` workspace: calls `startEditorTour()` directly. + - On any other workspace: `queuePendingAction('site.startTour')` (`@admin/spotlight/pendingAction`) then navigates to `/admin/site`. `SitePage`'s pending-action consumer fires `startEditorTour()` once the editor store hydrates (`site !== null`). +2. **Dashboard onboarding checklist** — the "Tour the editor" step (`OnboardingPanel.tsx`) queues the same `site.startTour` pending action and navigates to `/admin/site`. See [docs/features/dashboard.md](dashboard.md) → "Onboarding panel". + +Both replay paths work regardless of the persisted `editor-tour` outcome — only the auto-start effect checks for `null`. + +--- + +## Adding a new tour step + +1. Add a `data-testid` to the target element if it doesn't already have one. +2. Append a `TourStepDef` to `editorTourSteps` in `editorTourSteps.ts` — set `anchor` to the testid, `side`/`align` for bubble placement, and a `prepare()` if the anchor needs a panel opened or a selection made first. +3. Update the step count in any test/doc that hardcodes "7 steps" (`tests/e2e/editor-tour.e2e.ts`, this doc). + +## Adding a new tour (a different feature) + +1. Build a `TourStepDef[]` array — this doc's "The Site editor's tour" table is the template. +2. Write a `startTour()` function that calls `useTourStore.getState().start(steps, onEndCallback)`, where `onEndCallback` persists the outcome (a new user preference key — see [docs/reference/persistence-keys.md](../reference/persistence-keys.md) → "Add a server-persisted preference"). +3. Mount `` once, wherever the tour's anchors live (it's already mounted for the Site editor; a Content-workspace tour would mount its own). +4. Do not import `@site/*` or Site-editor-specific modules into `src/admin/shared/tour/` — the engine stays editor-agnostic. + +--- + +## Forbidden patterns + +| Pattern | Use instead | +|---|---| +| Reading/writing the `editor-tour` preference directly outside `useEditorTour.ts` | Call `startEditorTour()` for replay; read `useOnboardingState()`'s `tour` fact for onboarding-checklist status | +| Adding editor-specific logic (panel names, selection rules) to `src/admin/shared/tour/` | Keep it in `editorTourSteps.ts`'s `prepare()` callbacks — the engine stays reusable | +| A tour step anchor without a `data-testid` | Add one; `TourOverlay` only matches `[data-testid="..."]` | +| Assuming a "dismissed" tour should still count as done in onboarding UI | It doesn't — `useOnboardingState` only flips `tour: 'done'` on `status === 'completed'`, deliberately, so skipping isn't mistaken for learning the editor | +| A raw z-index for tour chrome | `--tour-z-index` (`src/styles/globals.css`) — see [docs/reference/design-tokens.md](../reference/design-tokens.md) | + +--- + +## Related + +- [docs/editor.md](../editor.md) → "Guided tour" — where this fits in the Site editor +- [docs/features/spotlight.md](spotlight.md) — the `help.editorTour` command and the `help` command group +- [docs/features/dashboard.md](dashboard.md) → "Onboarding panel" — the "Tour the editor" checklist step +- [docs/reference/persistence-keys.md](../reference/persistence-keys.md) — the `editor-tour` server-side preference +- [docs/reference/design-tokens.md](../reference/design-tokens.md) → "Z-index layers" — `--tour-z-index` +- Source-of-truth files: + - `src/admin/shared/tour/` — generic tour engine + - `src/admin/pages/site/tour/editorTourSteps.ts` — the 7 Site editor steps + - `src/admin/pages/site/tour/useEditorTour.ts` — auto-start + persistence + - `src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx` — mount point + - `src/admin/spotlight/commands/tour.ts` — replay command + - `src/admin/spotlight/pendingAction.ts` — `site.startTour` cross-workspace action + - `tests/e2e/editor-tour.e2e.ts` — TOUR-001–TOUR-003 end-to-end coverage diff --git a/docs/features/spotlight.md b/docs/features/spotlight.md index 2693287d0..b3b8d559f 100644 --- a/docs/features/spotlight.md +++ b/docs/features/spotlight.md @@ -159,6 +159,7 @@ The subscription is **dropped on close** to avoid spurious re-renders. | `settings` | Open framework scale, Open site settings | | `ai` | Open / focus AI assistant | | `account` / `users`| Account security, session revocation, user management | +| `help` | Take the editor tour (`help.editorTour`, see [docs/features/editor-tour.md](editor-tour.md)) | Each command's `when(ctx)` / `workspaces` / `capability` fields filter by user capability + workspace context. `filterCommands(commands, ctx)` runs once per palette open. diff --git a/docs/reference/design-tokens.md b/docs/reference/design-tokens.md index a87948ed4..94cd39859 100644 --- a/docs/reference/design-tokens.md +++ b/docs/reference/design-tokens.md @@ -383,18 +383,19 @@ Cards are filled and borderless; inputs are unfilled and bordered. That's the lo ## Z-index layers -Four global tokens cover the layered surfaces that float above the editor: +Five global tokens cover the layered surfaces that float above the editor: ```css --z-dropdown: 20; --spotlight-z-index: 9000; +--tour-z-index: 9500; --toast-z-index: 10000; --tooltip-z-index: 10001; ``` -Token names: `--z-dropdown`, `--spotlight-z-index`, `--toast-z-index`, `--tooltip-z-index`. +Token names: `--z-dropdown`, `--spotlight-z-index`, `--tour-z-index`, `--toast-z-index`, `--tooltip-z-index`. -`--tooltip-z-index` is deliberately the highest token so tooltips are never occluded by the surface their trigger lives on. `--toast-z-index` sits above modal layers and below tooltips. `--spotlight-z-index` is reused by several modal-level surfaces that need to sit above the editor chrome. +`--tooltip-z-index` is deliberately the highest token so tooltips are never occluded by the surface their trigger lives on. `--toast-z-index` sits above modal layers and below tooltips. `--spotlight-z-index` is reused by several modal-level surfaces that need to sit above the editor chrome. `--tour-z-index` sits above every editor surface (dialogs, panels, the Spotlight palette) so a guided-tour coach mark is never occluded mid-tour, but below toasts/tooltips — a toast error should still interrupt the tour, and a tooltip must always render on top. The tour's own bubble renders at `calc(--tour-z-index + 1)`, one layer above its backdrop, so it's never covered by its own scrim. See [docs/features/editor-tour.md](../features/editor-tour.md). **Global modal layer** (all raw values in the shared admin stacking context): @@ -403,10 +404,12 @@ Token names: `--z-dropdown`, `--spotlight-z-index`, `--toast-z-index`, `--toolti | 9000 | Spotlight backdrop (`--spotlight-z-index`); Settings modal backdrop; ModuleInserterDialog backdrop | | 9001 | Settings dialog wrapper (`--spotlight-z-index + 1`) | | 9050 | MediaPickerModal backdrop (`calc(--spotlight-z-index + 50)`) — sits above Settings because the picker can be opened from inside Settings (e.g. Settings → General → Favicon → Browse library…) | +| 9500 | Editor guided-tour backdrop (`--tour-z-index`) | +| 9501 | Editor guided-tour bubble (`calc(--tour-z-index + 1)`) | | 10000 | BodySlashMenu — predates tokenisation; see inline comment in `BodySlashMenu.module.css` | | 10001 | Tooltips (`--tooltip-z-index`); `AdminContextMenuGuard` | -The gap between 9001 (Settings dialog) and 9050 (MediaPickerModal) is intentional headroom for any future sub-dialogs inside Settings. The gap between 9050 and 10000 (BodySlashMenu) keeps the slash menu above all modal layers. Do not add new raw values into these ranges without updating this table. +The gap between 9001 (Settings dialog) and 9050 (MediaPickerModal) is intentional headroom for any future sub-dialogs inside Settings. `--tour-z-index` (9500) uses part of the headroom between 9050 and 10000 (BodySlashMenu), which still keeps the slash menu above all modal layers. Do not add new raw values into these ranges without updating this table. The visual editor uses additional raw z-index values that are **not** tokenised. They fall into two independent stacking contexts: diff --git a/docs/reference/persistence-keys.md b/docs/reference/persistence-keys.md index ded530988..a9fbfa4b8 100644 --- a/docs/reference/persistence-keys.md +++ b/docs/reference/persistence-keys.md @@ -55,6 +55,7 @@ Stored in the `user_preferences` table — one row per `(user_id, key)`. Keys ar |-------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------| | `dashboard-layout` | Dashboard widget positions / sizes | `src/admin/pages/dashboard/hooks/useDashboardLayout.ts` | | `module-inserter` | Module inserter notch favorites: ordered `{ kind, id }` refs for modules, layouts, and Visual Components | `src/admin/pages/site/module-picker/useModuleInserterPreference.ts` | +| `editor-tour` | Site editor guided-tour outcome — `{ status: 'completed' \| 'dismissed' }`, unset (`null`) means never seen and auto-starts the tour | `src/admin/pages/site/tour/useEditorTour.ts` | ### Endpoint @@ -208,6 +209,7 @@ for (const key of Object.keys(localStorage)) { - [docs/features/editor-preferences.md](../features/editor-preferences.md) — the canonical preference catalog - [docs/features/dashboard.md](../features/dashboard.md) — dashboard layout persistence - [docs/features/spotlight.md](../features/spotlight.md) — Spotlight recents + telemetry +- [docs/features/editor-tour.md](../features/editor-tour.md) — the `editor-tour` preference's auto-start/replay lifecycle - [docs/reference/typebox-patterns.md](typebox-patterns.md) — `parseJsonWithFallback`, `safeParseJson` - Source-of-truth files (selected): - `src/admin/pages/site/preferences/editorPreferences.ts` — `EDITOR_PREFS_KEY` From 402713af9d9c46fe1c9f25e64bd85997efdfa3df Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Sun, 16 Aug 2026 16:18:44 -0400 Subject: [PATCH 15/21] test(e2e): correct tour spec docstring range --- tests/e2e/editor-tour.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/editor-tour.e2e.ts b/tests/e2e/editor-tour.e2e.ts index 5c49cfce3..8345ad576 100644 --- a/tests/e2e/editor-tour.e2e.ts +++ b/tests/e2e/editor-tour.e2e.ts @@ -8,7 +8,7 @@ import { } from './helpers' /** - * TOUR-001 through TOUR-004 — the Site editor's first-run guided tour. + * TOUR-001 through TOUR-003 — the Site editor's first-run guided tour. * * A fresh user (no `editor-tour` preference set) auto-starts the tour on * opening `/admin/site`; stepping through all seven steps or skipping early From aeb68d77dca853faebc8e9a88afac35210d0c20c Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Tue, 18 Aug 2026 16:06:25 -0400 Subject: [PATCH 16/21] fix(admin): animate the tour bubble between steps instead of remounting The coach-mark bubble was remounted via key={stepIndex} on every step, so it always popped in fresh at (0, 0) before its position was computed instead of moving from the previous step's position to the next. TourOverlayInner now owns a single persistent bubble + backdrop for the whole tour run; the per-step key={stepIndex} logic becomes a non-visual TourStepResolver that reports {step, anchorEl} upward once resolved, so the bubble keeps showing the previous step's content in place while the next one resolves and transitions smoothly via CSS once it lands. Centered steps now compute a pixel position too instead of a CSS top:50% override, so they animate the same way. --- docs/features/editor-tour.md | 6 +- src/admin/shared/tour/TourOverlay.module.css | 32 +- src/admin/shared/tour/TourOverlay.test.tsx | 34 ++ src/admin/shared/tour/TourOverlay.tsx | 376 +++++++++++-------- 4 files changed, 271 insertions(+), 177 deletions(-) diff --git a/docs/features/editor-tour.md b/docs/features/editor-tour.md index 2607ff2c3..a740ecfa1 100644 --- a/docs/features/editor-tour.md +++ b/docs/features/editor-tour.md @@ -74,10 +74,10 @@ interface TourState { Portal-rendered to `document.body`. Three-component shape: - `TourOverlay` bails out before any hooks run when `steps === null` — no idle-render hook cost. -- `TourOverlayInner` owns subscriptions that live for the whole tour (current step index, the window-level Escape-to-dismiss listener). -- `TourStep`, remounted via `key={stepIndex}` on every step change, owns the per-step lifecycle: run `prepare()`, then locate the anchor (or go straight to a centered layout) via `waitForAnchor`, which polls `document.querySelector('[data-testid=""]')` once per animation frame. +- `TourOverlayInner` owns subscriptions that live for the whole tour (current step index, the window-level Escape-to-dismiss listener) **and** the persistent backdrop + bubble DOM. The bubble is a single element for the entire tour run — it never remounts between steps — so its position transitions smoothly via CSS (`--tour-x`/`--tour-y`, see `TourOverlay.module.css`) instead of popping in fresh at `(0, 0)` every step. `displayed` state holds the last step that finished resolving (content + anchor element); while the *next* step is still resolving, the bubble keeps showing `displayed`'s content in place — no blank gap, no flash. +- `TourStepResolver`, remounted via `key={stepIndex}` on every step change, is a **non-visual** per-step controller: run `prepare()`, then locate the anchor (or go straight to a centered layout) via `waitForAnchor`, which polls `document.querySelector('[data-testid=""]')` once per animation frame, and report the result upward through an `onResolved` callback (`TourOverlayInner`'s `setDisplayed` state setter, always referentially stable). Calling `onResolved`/`onNext` only ever happens *after* an `await`, so none of this trips the `react-hooks/set-state-in-effect` lint rule. -Anchored steps render an SVG mask cutting a rounded-rect "spotlight" hole around the anchor's `getBoundingClientRect()`, inflated by 6px; a `ResizeObserver` + scroll/resize listeners keep the cutout and bubble glued to the anchor as it moves. Centered steps (`anchor: null` — welcome/finish) render a flat scrim instead. The bubble itself (`role="dialog"`, `aria-modal="true"`, focused on mount) shows "Step N of M", title, body, and Skip/Back/Next actions; its position is computed by `computeFloatingPosition` (`@ui/lib/floatingPosition`) the same way tooltips are placed. +Anchored steps render an SVG mask cutting a rounded-rect "spotlight" hole around the anchor's `getBoundingClientRect()`, inflated by 6px; a `ResizeObserver` + scroll/resize listeners (owned by `TourOverlayInner`, keyed off the displayed step) keep the cutout and bubble glued to the anchor as it moves. Centered steps (`anchor: null` — welcome/finish) render a flat scrim instead, and the bubble is centered by computing a pixel `--tour-x`/`--tour-y` for the viewport center rather than a CSS `top: 50%` override — so centered steps animate through the same transform as anchored ones. The bubble itself (`role="dialog"`, `aria-modal="true"`, focused when a *new* step's content lands — not on every reposition) shows "Step N of M", title, body, and Skip/Back/Next actions; its position is computed by `computeFloatingPosition` (`@ui/lib/floatingPosition`) the same way tooltips are placed. --- diff --git a/src/admin/shared/tour/TourOverlay.module.css b/src/admin/shared/tour/TourOverlay.module.css index 540a97a3e..6f1057edb 100644 --- a/src/admin/shared/tour/TourOverlay.module.css +++ b/src/admin/shared/tour/TourOverlay.module.css @@ -5,10 +5,13 @@ * * Bubble position is applied via CSS custom properties injected through * inline style (the one sanctioned use of inline `style`): - * --tour-x / --tour-y translate offset from top-left, for anchored steps + * --tour-x / --tour-y translate offset from top-left * - * Centered steps (no anchor) ignore --tour-x/--tour-y entirely and are - * placed by the `[data-position="centered"]` override below instead. + * Centered steps (no anchor) compute a pixel --tour-x/--tour-y too (see + * computeCenteredPosition in TourOverlay.tsx), so every step — anchored or + * centered — moves through the same `transform` transition: the bubble is + * one persistent element for the whole tour, and this is what lets it glide + * from the previous step's position to the next instead of popping in. * * No !important, no hardcoded colours, no Tailwind utilities. */ @@ -77,12 +80,20 @@ color: var(--text); transition: transform 160ms ease, opacity 120ms ease; + /* Plays once, when the bubble first mounts for a tour run (it's a + single persistent element after that — see module doc above) — so a + brand-new bubble fades in already at its computed position instead + of flashing at (0, 0). */ + animation: tourBubbleIn 160ms ease-out; } -.bubble[data-position='centered'] { - top: 50%; - left: 50%; - transform: translate3d(-50%, -50%, 0); +@keyframes tourBubbleIn { + from { + opacity: 0; + } + to { + opacity: 1; + } } .progress { @@ -125,11 +136,12 @@ /* ── Reduced motion ─────────────────────────────────────────────────────── */ /* Durations are already clamped globally (see src/styles/globals.css), this - just drops the backdrop's entrance animation entirely rather than let it - play at 0.01ms. */ + just drops the entrance animations entirely rather than let them play at + 0.01ms. */ @media (prefers-reduced-motion: reduce) { - .backdrop { + .backdrop, + .bubble { animation: none; } } diff --git a/src/admin/shared/tour/TourOverlay.test.tsx b/src/admin/shared/tour/TourOverlay.test.tsx index e0c20656d..9a6726b47 100644 --- a/src/admin/shared/tour/TourOverlay.test.tsx +++ b/src/admin/shared/tour/TourOverlay.test.tsx @@ -59,6 +59,40 @@ describe('TourOverlay', () => { expect(onEnd).not.toHaveBeenCalled() }) + it('keeps the same bubble element across a step transition — no remount, no blank gap', async () => { + createAnchor('target-a') + createAnchor('target-b') + const steps: TourStepDef[] = [ + { id: 'a', anchor: 'target-a', title: 'First step', body: 'Body A' }, + { id: 'b', anchor: 'target-b', title: 'Second step', body: 'Body B' }, + ] + const onEnd = mock() + + render() + act(() => { + useTourStore.getState().start(steps, onEnd) + }) + + const firstDialog = await screen.findByRole('dialog') + expect(within(firstDialog).getByText('First step')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + // Immediately after the click — before step 2 has finished resolving + // its anchor — the bubble is still in the document showing step 1's + // content: no unmount, no blank gap while the next step resolves. + expect(screen.getByRole('dialog')).toBe(firstDialog) + expect(within(screen.getByRole('dialog')).getByText('First step')).toBeTruthy() + + await waitFor(() => { + expect(within(screen.getByRole('dialog')).getByText('Second step')).toBeTruthy() + }) + + // Same DOM node throughout the transition — content swapped in place + // rather than a fresh bubble popping in for step 2. + expect(screen.getByRole('dialog')).toBe(firstDialog) + }) + it('Escape dismisses the tour and onEnd receives "dismissed"', async () => { createAnchor('target-esc') const steps: TourStepDef[] = [{ id: 'a', anchor: 'target-esc', title: 'Step', body: 'Body' }] diff --git a/src/admin/shared/tour/TourOverlay.tsx b/src/admin/shared/tour/TourOverlay.tsx index f58e7f39e..c2922264c 100644 --- a/src/admin/shared/tour/TourOverlay.tsx +++ b/src/admin/shared/tour/TourOverlay.tsx @@ -11,13 +11,28 @@ * show (same trick as `Tooltip`'s `disabled` path), so the idle render * stays hook-free without violating the rules of hooks. * - `TourOverlayInner` owns the store subscriptions that live for the - * whole tour (current step index, the Escape-to-dismiss listener). - * - `TourStep`, remounted via `key={stepIndex}` on every step change, owns - * the per-step lifecycle: `step.prepare?.()`, then locating the anchor - * (or going straight to centered). Remounting instead of resetting - * state in an effect means each step starts from real `useState` - * initial values — no imperative "clear the previous step's state" - * effect needed. + * whole tour (current step index, the Escape-to-dismiss listener) AND + * the persistent backdrop + bubble DOM. The bubble is a single element + * for the whole tour — it never remounts between steps — so its + * position can transition smoothly via CSS instead of popping in fresh + * at (0, 0) every step. `displayed` holds the last step that finished + * resolving (content + anchor element); while the *next* step is still + * resolving, the bubble keeps showing `displayed`'s content in place — + * no blank gap, no flash. + * - `TourStepResolver`, remounted via `key={stepIndex}` on every step + * change, is a non-visual controller that runs the per-step lifecycle: + * `step.prepare?.()`, then locates the anchor (or goes straight to + * centered), and reports the result upward through the `onResolved` + * prop (`setDisplayed` itself — a `useState` setter, always stable). + * Remounting instead of resetting state in an effect means each step's + * resolver starts from real `useState`/closure initial values — no + * imperative "clear the previous step's state" effect needed, and + * since `onResolved`/`onNext` are only ever called *after* an `await`, + * none of this trips `react-hooks/set-state-in-effect` (that rule + * targets synchronous setState at the top of an effect — which is + * exactly what remounting-by-key was already avoiding for local state, + * and an async call after a real await is a genuine effect, not a + * "should've been computed during render" case). * * A step that never finds its anchor is soft-skipped: `waitForAnchor` polls * `[data-testid=""]` for up to two seconds, then — on timeout — @@ -74,7 +89,27 @@ function waitForAnchor(testId: string, timeoutMs: number): Promise s.back) const dismiss = useTourStore((s) => s.dismiss) + const titleId = useId() + const maskId = `${titleId}-mask` + const bubbleRef = useRef(null) + + const [displayed, setDisplayed] = useState(null) + const [anchorRect, setAnchorRect] = useState(null) + const [position, setPosition] = useState(null) + // Escape dismisses the tour from anywhere while it's active — the whole // point of a passive coach mark is that the user can bail without - // hunting for a close button. Lives here (not in `TourStep`, which - // remounts every step) so it stays subscribed across step changes. + // hunting for a close button. Lives here (not in the per-step resolver, + // which remounts every step) so it stays subscribed across step changes. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return @@ -103,44 +146,170 @@ function TourOverlayInner({ steps }: { steps: TourStepDef[] }) { return () => window.removeEventListener('keydown', onKeyDown) }, [dismiss]) + // Track the displayed step's anchor (or centered layout) and keep the + // bubble glued to it: re-measures on the anchor's own resize, the + // bubble's own resize (content reflow between steps), window resize, and + // scroll. `useLayoutEffect`, not `useEffect`, so a freshly-resolved + // step's position is committed to the DOM before the browser paints — + // that's what keeps a brand-new bubble from ever flashing at (0, 0). + useLayoutEffect(() => { + const bubbleEl = bubbleRef.current + if (!displayed || !bubbleEl) { + setAnchorRect(null) + return + } + + const recompute = () => { + const rect = displayed.anchorEl ? displayed.anchorEl.getBoundingClientRect() : null + setAnchorRect(rect) + const { width, height } = bubbleEl.getBoundingClientRect() + if (rect) { + const computed = computeFloatingPosition(rect, { + floatingWidth: width, + floatingHeight: height, + side: displayed.step.side ?? 'auto', + align: displayed.step.align ?? 'center', + offset: BUBBLE_OFFSET, + edgePadding: BUBBLE_EDGE_PADDING, + autoPriority: BUBBLE_AUTO_PRIORITY, + }) + setPosition({ x: computed.x, y: computed.y, side: computed.side }) + } else { + setPosition(computeCenteredPosition(width, height)) + } + } + + recompute() + + const observers: ResizeObserver[] = [] + if (typeof ResizeObserver !== 'undefined') { + // The bubble's own size (content reflow between steps) affects where + // it should sit even when the anchor hasn't moved. + const bubbleObserver = new ResizeObserver(recompute) + bubbleObserver.observe(bubbleEl) + observers.push(bubbleObserver) + if (displayed.anchorEl) { + const anchorObserver = new ResizeObserver(recompute) + anchorObserver.observe(displayed.anchorEl) + observers.push(anchorObserver) + } + } + window.addEventListener('resize', recompute) + // Capture phase: any scrollable ancestor (not just window) can move the + // anchor, and scroll events don't bubble. + document.addEventListener('scroll', recompute, true) + return () => { + observers.forEach((observer) => observer.disconnect()) + window.removeEventListener('resize', recompute) + document.removeEventListener('scroll', recompute, true) + } + }, [displayed]) + + // Focus the bubble when a NEW step's content lands — not on every + // reposition (the effect above re-runs far more often than `displayed` + // changes, e.g. on every scroll). + useEffect(() => { + if (!displayed) return + bubbleRef.current?.focus() + }, [displayed]) + + const anchored = displayed !== null && anchorRect !== null + const isFirstStep = displayed !== null && displayed.stepNumber === 1 + const isLastStep = displayed !== null && displayed.stepNumber === steps.length + + const bubbleStyle = { + '--tour-x': position ? `${position.x}px` : '0px', + '--tour-y': position ? `${position.y}px` : '0px', + } as CSSProperties + return ( - + <> + + {displayed && + createPortal( + <> +
+ {anchored && anchorRect && ( + + )} +
+
+

+ Step {displayed.stepNumber} of {steps.length} +

+

+ {displayed.step.title} +

+

{displayed.step.body}

+
+ +
+ {!isFirstStep && ( + + )} + +
+
+
+ , + document.body, + )} + ) } -interface TourStepProps { +interface TourStepResolverProps { step: TourStepDef stepNumber: number - totalSteps: number + onResolved: (resolved: DisplayedStep) => void onNext: () => void - onBack: () => void - onDismiss: () => void } -function TourStep({ step, stepNumber, totalSteps, onNext, onBack, onDismiss }: TourStepProps) { - const isFirstStep = stepNumber === 1 - const isLastStep = stepNumber === totalSteps - const titleId = useId() - const maskId = `${titleId}-mask` - const bubbleRef = useRef(null) - - const [anchorEl, setAnchorEl] = useState(null) - const [anchorRect, setAnchorRect] = useState(null) - const [ready, setReady] = useState(false) - const [position, setPosition] = useState(null) - - // Locate this step's target: run `prepare()`, then either go straight to - // "ready" (centered step) or wait for the anchor to appear. `cancelled` - // guards against this component unmounting (the store moved to a - // different step, or ended the tour) while the wait is still pending. +/** + * Non-visual per-step controller — renders nothing. Remounted via + * `key={stepIndex}` in `TourOverlayInner`, so every step starts this + * lifecycle fresh: run `step.prepare?.()`, then either go straight to + * "resolved" (centered step) or wait for the anchor to appear. `cancelled` + * guards against this component unmounting (the store moved to a + * different step, or ended the tour) while the wait is still pending. + */ +function TourStepResolver({ step, stepNumber, onResolved, onNext }: TourStepResolverProps) { useEffect(() => { let cancelled = false @@ -156,7 +325,7 @@ function TourStep({ step, stepNumber, totalSteps, onNext, onBack, onDismiss }: T if (cancelled) return if (step.anchor === null) { - setReady(true) + onResolved({ step, stepNumber, anchorEl: null }) return } @@ -169,135 +338,14 @@ function TourStep({ step, stepNumber, totalSteps, onNext, onBack, onDismiss }: T return } - setAnchorEl(el) - setAnchorRect(el.getBoundingClientRect()) - setReady(true) + onResolved({ step, stepNumber, anchorEl: el }) } run() return () => { cancelled = true } - }, [step, onNext]) + }, [step, stepNumber, onResolved, onNext]) - // Re-measure the anchor's rect on resize, scroll, or its own layout - // changes, so the spotlight cutout and bubble position stay glued to it. - useEffect(() => { - if (!anchorEl) return - const measure = () => setAnchorRect(anchorEl.getBoundingClientRect()) - const observers: ResizeObserver[] = [] - if (typeof ResizeObserver !== 'undefined') { - const anchorObserver = new ResizeObserver(measure) - anchorObserver.observe(anchorEl) - observers.push(anchorObserver) - if (bubbleRef.current) { - const bubbleObserver = new ResizeObserver(measure) - bubbleObserver.observe(bubbleRef.current) - observers.push(bubbleObserver) - } - } - window.addEventListener('resize', measure) - // Capture phase: any scrollable ancestor (not just window) can move the - // anchor, and scroll events don't bubble. - document.addEventListener('scroll', measure, true) - return () => { - observers.forEach((observer) => observer.disconnect()) - window.removeEventListener('resize', measure) - document.removeEventListener('scroll', measure, true) - } - }, [anchorEl]) - - // Compute the bubble's floating position once it's measurable. Centered - // steps (no anchor, `anchorRect` stays null) are placed by CSS alone. - useLayoutEffect(() => { - if (!ready || !anchorRect) return - const bubbleEl = bubbleRef.current - if (!bubbleEl) return - const { width, height } = bubbleEl.getBoundingClientRect() - const computed = computeFloatingPosition(anchorRect, { - floatingWidth: width, - floatingHeight: height, - side: step.side ?? 'auto', - align: step.align ?? 'center', - offset: BUBBLE_OFFSET, - edgePadding: BUBBLE_EDGE_PADDING, - autoPriority: BUBBLE_AUTO_PRIORITY, - }) - setPosition({ x: computed.x, y: computed.y, side: computed.side }) - }, [ready, anchorRect, step.side, step.align]) - - // Focus the bubble once it mounts so screen readers announce it and - // keyboard focus doesn't stay pinned to whatever was focused before. - useEffect(() => { - if (!ready) return - bubbleRef.current?.focus() - }, [ready]) - - if (!ready) return null - - const anchored = anchorRect !== null - - const bubbleStyle = { - '--tour-x': position ? `${position.x}px` : '0px', - '--tour-y': position ? `${position.y}px` : '0px', - } as CSSProperties - - return createPortal( - <> -
- {anchored && anchorRect && ( - - )} -
-
-

- Step {stepNumber} of {totalSteps} -

-

- {step.title} -

-

{step.body}

-
- -
- {!isFirstStep && ( - - )} - -
-
-
- , - document.body, - ) + return null } From 87be59fe742a7c91069e78542e968f980ad6687b Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Tue, 18 Aug 2026 16:06:40 -0400 Subject: [PATCH 17/21] fix(editor): point the framework tour step at the rail icon User feedback: the framework step showed the panel but never told people to click the rail icon to get there. Anchor the step on panel-rail-framework (the left-rail icon) instead of framework-panel, keep prepare() opening the panel so it's visible next to the spotlit icon, and lead the copy with "click this icon" instead of describing the panel as already open. The icon's deterministic rail accent resolves to 'violet' (var(--accent-9)), which renders as magenta/ fuchsia rather than purple, so the copy names that color instead. --- docs/features/editor-tour.md | 4 +++- src/admin/pages/site/tour/editorTourSteps.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/features/editor-tour.md b/docs/features/editor-tour.md index a740ecfa1..ba3877a0e 100644 --- a/docs/features/editor-tour.md +++ b/docs/features/editor-tour.md @@ -92,13 +92,15 @@ Anchored steps render an SVG mask cutting a rounded-rect "spotlight" hole around | 3 | `new-page` | `site-explorer-new-page` | opens the Explorer's Site tab | | 4 | `modules` | `canvas-notch` | — | | 5 | `properties` | `properties-panel` | docks the Properties panel; selects the active page's root node if nothing is selected (the panel only renders docked + expanded + with a selection) | -| 6 | `framework` | `framework-panel` | opens the Framework left-sidebar panel | +| 6 | `framework` | `panel-rail-framework` | opens the Framework left-sidebar panel (anchor is the rail icon that opens it, not the panel itself — see below) | | 7 | `publish` | `toolbar-publish-btn` | *(centered — finish)* | Step 5 (`properties`) is the one step whose anchor needs more than a panel-mode flip: `[data-testid="properties-panel"]` only renders when the panel is docked, not collapsed, **and** something is selected (a node, a selector class, or a selector multi-select — see `selectRightSidebarExpanded` in `@site/store/store` and the early-return in `PropertiesPanel.tsx`). A fresh session usually has no selection, so `dockPropertiesPanelWithSelection()` also selects the active page's root node when the selection is empty — `applySelection` (`selectionSlice.ts`) clears `propertiesPanel.collapsed` as a side effect, so the panel renders for free. The `site-explorer-new-page` testid is wired through `SiteExplorerTreeSection`'s `actionTestId` prop (`src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanelSections.tsx`). +Step 6 (`framework`) anchors on `panel-rail-framework` — the left-rail icon (`PanelRail.tsx`) — rather than `framework-panel`, so the spotlight sits on the button that *opens* the panel, and the copy leads with "click this icon" rather than describing the panel as if it were already in front of the user. `prepare()` still opens the panel (`openFrameworkPanel`) so it's visibly docked next to the spotlit icon for context. The icon's rail accent is assigned deterministically by identity hash (`assignRailAccents` in `src/ui/railAccent.ts`) and currently resolves to `'violet'` (`var(--accent-9)`, a magenta/fuchsia hue, not a true purple/violet) — the copy names the color it actually renders as. If the rail's accent assignment or item order ever changes, re-check this against the copy. + --- ## Lifecycle — auto-start, replay, persistence diff --git a/src/admin/pages/site/tour/editorTourSteps.ts b/src/admin/pages/site/tour/editorTourSteps.ts index 4d305fd39..a1981018a 100644 --- a/src/admin/pages/site/tour/editorTourSteps.ts +++ b/src/admin/pages/site/tour/editorTourSteps.ts @@ -86,9 +86,9 @@ export const editorTourSteps: TourStepDef[] = [ }, { id: 'framework', - anchor: 'framework-panel', + anchor: 'panel-rail-framework', title: 'Your design variables', - body: 'The Framework panel holds your site-wide design tokens — Colors, Type and Space — as CSS :root variables. Change them once, they update everywhere.', + body: 'Click this magenta Framework icon in the left rail to open your design variables — site-wide Colors, Type and Space tokens. Change them once, they update everywhere.', side: 'right', prepare: openFrameworkPanel, }, From 3eb81f71f28b2942fdf02e058ba8eb471b9064a6 Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Tue, 18 Aug 2026 16:07:43 -0400 Subject: [PATCH 18/21] fix(editor): call the framework rail icon purple in the tour copy --- docs/features/editor-tour.md | 2 +- src/admin/pages/site/tour/editorTourSteps.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/editor-tour.md b/docs/features/editor-tour.md index ba3877a0e..f10758fef 100644 --- a/docs/features/editor-tour.md +++ b/docs/features/editor-tour.md @@ -99,7 +99,7 @@ Step 5 (`properties`) is the one step whose anchor needs more than a panel-mode The `site-explorer-new-page` testid is wired through `SiteExplorerTreeSection`'s `actionTestId` prop (`src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanelSections.tsx`). -Step 6 (`framework`) anchors on `panel-rail-framework` — the left-rail icon (`PanelRail.tsx`) — rather than `framework-panel`, so the spotlight sits on the button that *opens* the panel, and the copy leads with "click this icon" rather than describing the panel as if it were already in front of the user. `prepare()` still opens the panel (`openFrameworkPanel`) so it's visibly docked next to the spotlit icon for context. The icon's rail accent is assigned deterministically by identity hash (`assignRailAccents` in `src/ui/railAccent.ts`) and currently resolves to `'violet'` (`var(--accent-9)`, a magenta/fuchsia hue, not a true purple/violet) — the copy names the color it actually renders as. If the rail's accent assignment or item order ever changes, re-check this against the copy. +Step 6 (`framework`) anchors on `panel-rail-framework` — the left-rail icon (`PanelRail.tsx`) — rather than `framework-panel`, so the spotlight sits on the button that *opens* the panel, and the copy leads with "click this icon" rather than describing the panel as if it were already in front of the user. `prepare()` still opens the panel (`openFrameworkPanel`) so it's visibly docked next to the spotlit icon for context. The icon's rail accent is assigned deterministically by identity hash (`assignRailAccents` in `src/ui/railAccent.ts`) and currently resolves to `'violet'` (`var(--accent-9)`, a purple/fuchsia hue) — the copy calls it "purple", matching the accent's own name and how users describe it. If the rail's accent assignment or item order ever changes, re-check this against the copy. --- diff --git a/src/admin/pages/site/tour/editorTourSteps.ts b/src/admin/pages/site/tour/editorTourSteps.ts index a1981018a..3780b2c9e 100644 --- a/src/admin/pages/site/tour/editorTourSteps.ts +++ b/src/admin/pages/site/tour/editorTourSteps.ts @@ -88,7 +88,7 @@ export const editorTourSteps: TourStepDef[] = [ id: 'framework', anchor: 'panel-rail-framework', title: 'Your design variables', - body: 'Click this magenta Framework icon in the left rail to open your design variables — site-wide Colors, Type and Space tokens. Change them once, they update everywhere.', + body: 'Click this purple Framework icon in the left rail to open your design variables — site-wide Colors, Type and Space tokens. Change them once, they update everywhere.', side: 'right', prepare: openFrameworkPanel, }, From 01c62db832f4c6f5cb3f9b0a2a124c8c1cd8dc2f Mon Sep 17 00:00:00 2001 From: Yuri Korolev Date: Tue, 18 Aug 2026 16:16:35 -0400 Subject: [PATCH 19/21] fix(admin): sharpen the tour spotlight ring and point the bubble at its anchor --- src/admin/shared/tour/TourOverlay.module.css | 75 +++++++++++++++++++- src/admin/shared/tour/TourOverlay.test.tsx | 5 ++ src/admin/shared/tour/TourOverlay.tsx | 62 ++++++++++++---- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/src/admin/shared/tour/TourOverlay.module.css b/src/admin/shared/tour/TourOverlay.module.css index 6f1057edb..c93d8b6d9 100644 --- a/src/admin/shared/tour/TourOverlay.module.css +++ b/src/admin/shared/tour/TourOverlay.module.css @@ -5,7 +5,8 @@ * * Bubble position is applied via CSS custom properties injected through * inline style (the one sanctioned use of inline `style`): - * --tour-x / --tour-y translate offset from top-left + * --tour-x / --tour-y translate offset from top-left + * --tour-arrow-offset cross-axis position of the bubble's arrow tip * * Centered steps (no anchor) compute a pixel --tour-x/--tour-y too (see * computeCenteredPosition in TourOverlay.tsx), so every step — anchored or @@ -47,6 +48,25 @@ transition: x 160ms ease, y 160ms ease, width 160ms ease, height 160ms ease; } +/* Traces the cutout edge so the target reads as "the one we mean" even when + the dim scrim over an already-dark UI doesn't create much contrast on its + own. Reuses --canvas-selection-ring-color, the canvas's own "this element + is the one" affordance — same colour vocabulary, new context. */ +.spotlightRing { + stroke: var(--canvas-selection-ring-color); + stroke-width: 1.5px; + transition: x 160ms ease, y 160ms ease, width 160ms ease, height 160ms ease; +} + +/* Wider, low-opacity stroke of the same token sitting behind .spotlightRing + for a soft glow — stroke-opacity only, no second colour. */ +.spotlightRingGlow { + stroke: var(--canvas-selection-ring-color); + stroke-width: 4px; + stroke-opacity: 0.25; + transition: x 160ms ease, y 160ms ease, width 160ms ease, height 160ms ease; +} + @keyframes tourBackdropIn { from { opacity: 0; @@ -64,6 +84,7 @@ left: 0; --tour-x: 0px; --tour-y: 0px; + --tour-arrow-offset: 0px; transform: translate3d(var(--tour-x), var(--tour-y), 0); z-index: calc(var(--tour-z-index) + 1); @@ -134,6 +155,58 @@ gap: var(--space-s); } +/* ── Arrow ──────────────────────────────────────────────────────────────── */ +/* + * Same technique as Tooltip's arrow: a 6×6px square rotated 45° to a + * diamond, one corner poking past the bubble edge. Background matches the + * bubble; a border on only the two outward-facing sides reads as a thin + * outline on the exposed corner without doubling the bubble's own border. + * Positioned via --tour-arrow-offset (the bubble's own --tour-x/--tour-y + * pattern) so it tracks the anchor's cross-axis centre; flips side via the + * bubble's `data-side` attribute. It's a plain child of the bubble, so the + * bubble's transform transition carries it along — no separate animation. + */ + +.arrow { + position: absolute; + width: 6px; + height: 6px; + background: var(--bg-surface); + transform: rotate(45deg); +} + +/* Bubble above anchor → arrow at bottom, pointing down */ +.bubble[data-side='top'] .arrow { + bottom: -3px; + left: calc(var(--tour-arrow-offset) - 3px); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +/* Bubble below anchor → arrow at top, pointing up */ +.bubble[data-side='bottom'] .arrow { + top: -3px; + left: calc(var(--tour-arrow-offset) - 3px); + border-top: 1px solid var(--border); + border-left: 1px solid var(--border); +} + +/* Bubble left of anchor → arrow at right edge, pointing right */ +.bubble[data-side='left'] .arrow { + right: -3px; + top: calc(var(--tour-arrow-offset) - 3px); + border-top: 1px solid var(--border); + border-right: 1px solid var(--border); +} + +/* Bubble right of anchor → arrow at left edge, pointing left */ +.bubble[data-side='right'] .arrow { + left: -3px; + top: calc(var(--tour-arrow-offset) - 3px); + border-bottom: 1px solid var(--border); + border-left: 1px solid var(--border); +} + /* ── Reduced motion ─────────────────────────────────────────────────────── */ /* Durations are already clamped globally (see src/styles/globals.css), this just drops the entrance animations entirely rather than let them play at diff --git a/src/admin/shared/tour/TourOverlay.test.tsx b/src/admin/shared/tour/TourOverlay.test.tsx index 9a6726b47..d8af03711 100644 --- a/src/admin/shared/tour/TourOverlay.test.tsx +++ b/src/admin/shared/tour/TourOverlay.test.tsx @@ -48,6 +48,9 @@ describe('TourOverlay', () => { const dialog = await screen.findByRole('dialog') expect(within(dialog).getByText('Step 1 of 2')).toBeTruthy() expect(within(dialog).getByText('First step')).toBeTruthy() + // Anchored step: bubble resolves a side (drives which edge the arrow + // points from). + expect(dialog.getAttribute('data-side')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Next' })) @@ -57,6 +60,8 @@ describe('TourOverlay', () => { expect(within(screen.getByRole('dialog')).getByText('Last step')).toBeTruthy() expect(screen.getByRole('button', { name: 'Finish' })).toBeTruthy() expect(onEnd).not.toHaveBeenCalled() + // Centered step: no anchor side, so no arrow renders either. + expect(screen.getByRole('dialog').getAttribute('data-side')).toBeNull() }) it('keeps the same bubble element across a step transition — no remount, no blank gap', async () => { diff --git a/src/admin/shared/tour/TourOverlay.tsx b/src/admin/shared/tour/TourOverlay.tsx index c2922264c..80052c2bb 100644 --- a/src/admin/shared/tour/TourOverlay.tsx +++ b/src/admin/shared/tour/TourOverlay.tsx @@ -56,9 +56,11 @@ import styles from './TourOverlay.module.css' /** How long a step waits for its anchor before soft-skipping. */ const ANCHOR_WAIT_TIMEOUT_MS = 2000 -/** Outward inflation of the spotlight cutout past the anchor's own rect. */ -const SPOTLIGHT_INFLATE = 6 -const SPOTLIGHT_RADIUS = 8 +/** Outward inflation of the spotlight cutout past the anchor's own rect. Kept + * tight so the hole hugs the target instead of leaving visible slack around + * it. */ +const SPOTLIGHT_INFLATE = 4 +const SPOTLIGHT_RADIUS = 6 const BUBBLE_OFFSET = 12 const BUBBLE_EDGE_PADDING = 16 const BUBBLE_AUTO_PRIORITY = ['bottom', 'top', 'right', 'left'] as const @@ -91,6 +93,9 @@ interface BubblePosition { y: number /** `null` for centered steps — no anchor side to report. */ side: ResolvedFloatingSide | null + /** Cross-axis offset for the bubble's arrow tip. Unused (no arrow renders) + * for centered steps. */ + arrowOffset: number } /** Centers the bubble in the viewport — the pixel-position equivalent of a @@ -101,6 +106,7 @@ function computeCenteredPosition(width: number, height: number): BubblePosition x: Math.max(0, (window.innerWidth - width) / 2), y: Math.max(0, (window.innerHeight - height) / 2), side: null, + arrowOffset: 0, } } @@ -173,7 +179,12 @@ function TourOverlayInner({ steps }: { steps: TourStepDef[] }) { edgePadding: BUBBLE_EDGE_PADDING, autoPriority: BUBBLE_AUTO_PRIORITY, }) - setPosition({ x: computed.x, y: computed.y, side: computed.side }) + setPosition({ + x: computed.x, + y: computed.y, + side: computed.side, + arrowOffset: computed.arrowOffset, + }) } else { setPosition(computeCenteredPosition(width, height)) } @@ -217,9 +228,23 @@ function TourOverlayInner({ steps }: { steps: TourStepDef[] }) { const isFirstStep = displayed !== null && displayed.stepNumber === 1 const isLastStep = displayed !== null && displayed.stepNumber === steps.length + // Shared geometry for the mask cutout AND the visible ring traced around + // it — both need to move/resize in lockstep with the anchor, so it's + // computed once here rather than duplicated per ``. + const holeGeometry = anchorRect + ? { + x: anchorRect.left - SPOTLIGHT_INFLATE, + y: anchorRect.top - SPOTLIGHT_INFLATE, + width: anchorRect.width + SPOTLIGHT_INFLATE * 2, + height: anchorRect.height + SPOTLIGHT_INFLATE * 2, + rx: SPOTLIGHT_RADIUS, + } + : null + const bubbleStyle = { '--tour-x': position ? `${position.x}px` : '0px', '--tour-y': position ? `${position.y}px` : '0px', + '--tour-arrow-offset': position ? `${position.arrowOffset}px` : '0px', } as CSSProperties return ( @@ -235,21 +260,22 @@ function TourOverlayInner({ steps }: { steps: TourStepDef[] }) { createPortal( <>
- {anchored && anchorRect && ( + {anchored && holeGeometry && ( )}
@@ -264,6 +290,12 @@ function TourOverlayInner({ steps }: { steps: TourStepDef[] }) { data-side={position?.side ?? undefined} style={bubbleStyle} > + {/* Points the bubble at its anchor — omitted entirely for + centered steps (no anchor side to point along). Moves with + the bubble for free: it's a plain child riding the parent's + own transform/transition, positioned per-side by the CSS + keyed off the bubble's own `data-side`. */} + {anchored &&