Files

Onboarding system (developer guide)

The onboarding system runs short, anchored driver.js tours and orchestrates a cross-route guided sequence after a user connects their first provider. Everything lives in client state and localStorage — there is zero backend coupling.

Building blocks

Concern File
Flow registry (single source of truth) ui/lib/onboarding/registry.ts
Flow type (OnboardingFlow) ui/lib/onboarding/onboarding-types.ts
Tour definitions (*.tour.ts) ui/lib/tours/
Driver primitive (useDriverTour) ui/lib/tours/use-driver-tour.ts
Per-route trigger ui/components/onboarding/onboarding-trigger.tsx
Ephemeral sequence slice ui/store/onboarding-sequence.ts
Checkpoint watcher + dialog ui/components/onboarding/onboarding-checkpoint-{watcher,dialog}.tsx
Mandatory new-user gate ui/components/onboarding/onboarding-gate.tsx
Manual replay list ui/components/ui/user-nav/user-nav.tsx

How the guided sequence works

  1. The (prowler)/layout.tsx derives a tri-state hasProviders on every navigation and mounts <OnboardingCheckpointWatcher /> (sibling to the gate).
  2. When the watcher observes a concrete false → true hasProviders flip (the user actually connected a provider), it opens the checkpoint dialog once. An undefined → true (user already had providers) never fires. A localStorage marker (prowler.onboarding.checkpoint) prevents re-appearance.
  3. "Continue the tour" calls startSequence(nextFlowId) on the ephemeral useOnboardingSequenceStore and navigates to that flow's route.
  4. Each route mounts an <OnboardingTrigger flow={...} />. The trigger force starts the flow when slice.currentFlowId === flow.id (sequence) or when the ?onboarding=<id> param matches (replay). The StrictMode-safe latch / keyed runner / empty-deps force-start is preserved verbatim.
  5. On tour close, useDriverTour's onClosed(state) reports the outcome: completedadvance() (navigate to the next flow), skipped/dismissedstop() (the sequence ends; closing any tour ends the sequence).
  6. The slice is ephemeral (plain Zustand create, no persist). It carries currentFlowId across client navigations but resets on a hard reload, so a mid-sequence refresh never re-fires.

attack-paths is special: its page already owns a driver, so its registry entry sets ownsAutoOpen: true, the trigger does not mount a runner for it, and the page wires onClosed to the slice itself (single-fire).

Add a new flow (the extensibility contract)

A new flow is one registry entry + one tour file + its anchors + a trigger mount — no gate, modal, or nav edits.

  1. Tour fileui/lib/tours/<flow-id>.tour.ts via defineTour<Target> and the assets/tour-template.ts. Keep it shallow: a centered welcome step plus 12 anchored steps. coversFiles scopes the drift check.
  2. Anchors — add data-tour-id="<flow-id>-<target>" on the page-specific client component for each anchored step (never the shared Navbar). The tour file and its anchors MUST ship in the SAME PR (tour:check hard-fails a tour target with no matching anchor).
  3. Registry entry — add { id, order, title, description, route, tour } to onboardingFlows in registry.ts. Ordering is data (order).
  4. Trigger mount — render <OnboardingTrigger flow={getFlowById("<id>")!} /> inside the route's client host. Pass stepHandlers/configOverrides only if the flow needs them (e.g. add-provider opens the wizard).

The avatar "Product tour" submenu and advance() both derive from getOrderedFlows(), so a new flow appears in the replay list and participates in the sequence automatically.

CI gates

  • pnpm run tour:check (ui/scripts/check-tour-alignment.mjs) — every tour target must resolve to a real data-tour-id anchor within its coversFiles.
  • pnpm exec vitest run --project unit — pure logic (slice, helpers, registry, tour shapes). The driver primitive short-circuits in NODE_ENV==="test".