Open with AI
Guided tours
A tour follows the same idea as a screenshot: define it against the real app, in
the repo, and it can’t quietly rot. Steps anchor to elements with the same
data-shot selectors your shots use, live in a .tour.ts next to the feature
they tour, and ship in the same PR as the UI change that would otherwise break
them.
Two packages split the work:
@stillsmith/touris the production runtime: small, framework-neutral, two dependencies. This is what your app installs and ships.@stillsmith/captureis the dev dependency you already have. It authors tours, providing the editor’s tours mode, the codemod that writes your.tour.tsback, and the MCP tools for agents.
Declaring a tour
Section titled “Declaring a tour”import type { Tour } from "@stillsmith/tour";
export const Onboarding = { id: "onboarding", steps: [ { title: "Welcome", body: "A quick look around." }, { target: { selector: "[data-shot='search']" }, body: "Find anything from here." }, { target: { selector: "[data-shot='save']" }, body: "Go ahead and click it.", advance: { on: "click" }, }, { route: "/settings", target: { selector: "[data-shot='theme']" }, body: "Make it yours." }, ],} satisfies Tour;Named exports are tours, the same way named exports of a scene file are shots.
Targets resolve selector → text → rect, same as annotations, and the same
rule applies: a data-shot attribute is the anchor that survives.
Running it
Section titled “Running it”import { TourAutoStart } from "@stillsmith/tour/react";import { Onboarding } from "@/tours/onboarding.tour";
<TourAutoStart tour={Onboarding} />Or imperatively, with startTour(Onboarding). Progress persists in
localStorage: a completed or dismissed tour stays quiet, and an interrupted one
resumes where it left off. Esc dismisses, arrow keys step, and focus moves into
the card and back out at the end.
The runtime covers the awkward parts:
- Targets that mount late are polled for per-step.
optional: trueskips one that never shows; a required miss ends the tour with a console warning and no persisted verdict, so it tries again next visit. - The spotlight is an SVG cutout that passes clicks through to the real control,
which is what makes
advance: { on: "click" }work. - Tooltips flip and shift at viewport edges, and a step’s
offsetis applied last and always wins.
Fixtures: data the tour needs
Section titled “Fixtures: data the tour needs”A tour walks your real app, so a feature with nothing in it has nothing to point at. A brand-new account shows an empty list, and the step that spotlights a row waits eight seconds and gives up. Name a fixture and the tour brings its own data:
export const Onboarding = { id: "onboarding", fixture: "demo-rocks", steps: [/* … */],} satisfies Tour;The name is a string, not a function, for the same reason body is: it
round-trips through the authoring codemod. What it means lives in your app,
registered once at startup:
import { registerTourFixtures } from "@stillsmith/tour";
registerTourFixtures({ "demo-rocks": { setup() { shelfStore.add(DEMO_ROCKS); return () => shelfStore.remove(DEMO_ROCKS.map((r) => r.id)); }, },});setup runs before the first step; the cleanup it returns runs when the tour
ends, whichever exit it takes — completed, dismissed, or torn down. Return
nothing and provide a teardown instead if that reads better; a returned
cleanup wins if you do both. Either may be async, and the engine waits for
setup before showing step one.
Register at module scope. A tour can start before React has mounted anything, so a fixture registered inside an effect is often too late.
Three rules worth knowing:
- Setup must be idempotent. A tour the user abandoned resumes on their next visit and seeds again, over state that may or may not still hold the first seed. Add by id rather than appending blindly.
- An unregistered or failing fixture ends the tour with a console warning and no persisted verdict — so a misconfigured deploy doesn’t burn the tour’s one shot, and it tries again next visit. Same rule as a missing required target.
- Teardown isn’t transactional. If the user closes the tab mid-tour, nothing runs. Prefer client-side or clearly-flagged demo data over records you’d have to reach a server to delete.
For a controller wired by hand, TourOptions.fixtures passes handlers directly
and takes precedence over the registry:
startTour(Onboarding, { fixtures: { "demo-rocks": { setup } } });The authoring GUI and the MCP preview_step / inspect_app tools apply the
fixture too, so elements that only exist once data is present are targetable
while you author. In the GUI the seed stays until you reload the frame or switch
tours; a play-through seeds and cleans up on its own.
Authoring visually
Section titled “Authoring visually”Add the globs to your config:
export default defineConfig({ scenes: ["src/**/*.scene.tsx"], tours: ["src/tours/*.tour.ts"], // …});stillsmith dev then grows a tours mode next to shots. Its stage doesn’t render a
scene; it iframes your running app, with a route bar. Click an element to target
it (the same suggestion engine grades how stable the selector is), drag the card
to nudge its offset, or press play to run the real tour in the frame.
Rehearsals run with persistence off, so they never mark the real tour completed.
Save writes your .tour.ts back through the same codemod that writes shots: only
the steps and fixture properties are touched, minimal diff, your formatter.
By default the stage serves your app through stillsmith’s merged Vite server. If
your app runs its own dev server — a Next.js app under next dev, say — point
stillsmith at it instead:
appUrl: "http://localhost:3000",The stage then iframes that origin directly, no build integration at all. Note
that a cross-origin appUrl disables live step previews in the GUI (the
browser forbids reaching into the frame); the MCP preview_step tool renders
through Playwright and still works.
Agents
Section titled “Agents”The MCP server gains the tour counterparts of its scene tools: list_tours
(what exists), inspect_app (what’s targetable on a route, with stability
grades), and preview_step (render one step, saved or proposed inline, over the
live app and return the PNG). The injected runtime is resolved from your
@stillsmith/tour install, so what the agent sees is the version you ship.
Both inspect_app and preview_step seed the tour’s fixture first, so an agent
writing a step against seeded data sees the same page your users will.