# Walkabout

> Add a Walkabout to an app — a guided voice tour that walks the real pages with a narration-synced moving spotlight, an ask-the-app AI guide that logs every question, and one-command narrated demo videos. Use when adding onboarding tours, in-app help, or demo/training videos to any app.

- Skill: `jezweb/walkabout` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jezweb/walkabout`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jezweb/walkabout/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jezweb (https://skillmd.com/u/jezweb)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jezweb/walkabout

---


# Walkabout — the app demos itself

You're giving an app three connected capabilities, built in this order:

1. **The tour** — a floating guide card walks the REAL app page by page with
   ElevenLabs narration; the spotlight scrolls to each section as the voice
   reaches it; auto-advance makes it hands-free. No library — the bundled
   `Tour.tsx` is the whole thing.
2. **The Guide (ask-the-app AI)** — a corner button hosting the tour AND a
   question box answered by an LLM grounded in a hand-written app guide.
   Every question is logged: the log IS the product roadmap.
3. **Demo videos** — headless recorders that turn the tour (or any scripted
   click-path) into narrated MP4s. Three content tiers from one engine:
   quick highlights (socials), promo tour (2–3 min), training demos
   (30–60s per feature, with real typing/clicking on narration cue).

The goal: the owner never gives a demo again. The app onboards, explains,
and records itself.

All templates live in `${CLAUDE_PLUGIN_ROOT}/templates/` (this plugin's
`templates/` dir) — FieldProof's REAL working files, worked examples not
scaffolds. Read them, transplant them, adapt names/paths/styling to the host
app. The deep reference (design rationale, full gotcha list, adopter notes) is
`${CLAUDE_PLUGIN_ROOT}/docs/pattern.md` (also at github.com/jezweb/walkabout) —
update it when you learn something new, and add the app to its Adopters list.

## Phase 1 — the tour

Copy `Tour.tsx`, `steps.ts`, `halo.css` (append to the app's global CSS).
**Colour the halo from the host's `--primary` token, don't hardcode it.**
`halo.css` carries FieldProof's sage→green in a few spots (the `@property` ring
initial, the `tour-draw` keyframe, the `tour-breathe` glow) — ship those on a
navy app and the halo is green, invisibly wrong until mid-tour. Key the ring +
glow off `--primary` instead (`color-mix(in srgb, var(--primary) 55%, white)`
while the line draws, settling to `var(--primary)`; glow = `var(--primary)`):
the halo then brands itself AND survives a later reskin untouched. Proven on
RightCover — a navy rebrand recoloured the halo for free, zero tour edits,
because it referenced `--primary`. (`@property` initial-value can't take a
`var()`, so leave that neutral — only the keyframes need the token.) Then:

**Restyle for the host first — the templates carry the source app's classes.**
`Tour.tsx` and `Assist.tsx` use `brand-card`, `font-display`, `primary-dark`,
`bg-surface`, `text-warning`. On a shadcn host (the common case) map them once:
`brand-card` → `rounded-lg border bg-card`, `font-display` → drop, `primary-dark`
→ `primary`, `text-white` → `text-primary-foreground`, `bg-surface` →
`bg-background`, `text-warning` → `text-amber-600 dark:text-amber-400`.
The halo is `box-shadow`/conic-ring based (no `outline`); colour it from
`--primary` per the note above, not via these class swaps.

- Write 5–8 steps, one per page. Card `body` = 2 lines. Narration = 2–5
  `(selector, text)` segments per step in `gen-tour-audio.py`'s SCRIPTS dict
  — each segment describes ONE page section, top to bottom, like you're
  showing a mate. End the last step with "that's the tour".
- Add a `data-tour="…"` attribute to every element a segment describes.
- Run the generator (`ELEVENLABS_API_KEY` env var; voice Charlie
  `IKne3meq5aSn9XLyUdCD` is a warm Australian male; `eleven_turbo_v2_5` for
  drafts, a richer model for final renders). It writes `public/tour/*.mp3`
  AND `tour/cues.gen.ts` — commit both.
- Wire `useTour()` into the app shell; offer once on first sign-in
  (localStorage), restartable from a footer/menu, `?tour=N` deep links.

**Verification is wandering, not watching**: start the tour, then click
around mid-step. The guide must pause itself when you leave the step's page
("Paused while you explore"), resume where it left off, and NEVER replay
audio or yank you back when you click things. Run it on the page with the
highest z-index content (maps!) — the card is `z-[1100]` for a reason.

**Verifying with automation**: automated Chrome CAN play the narration — two
ways to grant it. Launch with `--autoplay-policy=no-user-gesture-required`
(the recorder templates already do), or start the tour with a REAL input
click — `page.click()` / `getByRole(...).click()` synthesise trusted events
and count as the gesture. What does NOT count: `page.evaluate(() =>
btn.click())` — a JS-initiated click grants no gesture, so `play()` rejects,
`ontimeupdate`/`onended` never fire, and the spotlight + auto-advance look
broken when they aren't. End-to-end proof in automation: start the tour and
wait for the step counter to advance by itself (proven on FieldProof —
headless deep-link runs auto-advance fine with the flag).

Traps that WILL bite if you deviate from the template (full list in the
knowledge doc):

- The narrate effect's deps are `[i]` ONLY. Adding `navigate` (identity
  changes per location) replays audio on every click — or hard-HANGS on
  self-redirecting index routes.
- Autoplay needs a gesture: the Start click is it. Deep-linked starts have
  none — catch the blocked `play()` and open paused.
- `arrivedRef` is what stops the wander-detector from pausing the tour
  during its own step navigation. Don't simplify it away.

## Phase 2 — the Guide (ask-the-app AI)

Copy `Assist.tsx`, `assist-routes.ts`, `assist-knowledge.ts`, `questions.sql`.

- **Rewrite `knowledge.ts` entirely** — it's the assistant's ONLY truth
  source. Plain prose: what the app is, every page, every flow, limits, who
  to contact. Facts from the code, never imagination (no invented pricing,
  stats, contacts). Leave a header comment: *update this file in the same
  commit as any feature change* — and add that rule to the app's CLAUDE.md.
- System prompt shape: answer only from the guide; defer to the human
  contact for anything else; plain text, under ~120 words. Use a cheap fast
  model with **thinking OFF** (reasoning models burn the budget and return
  null content on structured tasks).
- Log EVERY question (asker, page path, latency, answer/error) and surface
  the log on a page linked from the widget. Never skip the logging — the
  questions are the roadmap.
- The FAB hides while the tour or its offer occupies the corner: one corner,
  one entry point.

Verify live with three questions: one the guide covers (expect a grounded,
specific answer), one it doesn't — pricing works well — (expect a plain
"the guide doesn't cover this" + contact, NOT an invention), and one from a
specific page (the page path is sent as context).

## Phase 3 — demo videos

Copy `record-tour.mjs` and `record-demo.mjs` into the app's scripts dir.
Needs: `playwright` devDependency, `ffmpeg`/`ffprobe` on PATH, seeded data
that looks good on camera, and a headless-friendly sign-in.

**Headless auth is the real blocker, not a one-liner — solve it first.** The
templates' `localStorage.setItem('<app>:api_key', …)` bootstrap ONLY works for
API-key auth. Cookie/OAuth apps (better-auth and most modern stacks) can't do
that — and you do NOT add an API-key feature just to record. Two real options:
- **Playwright `storageState` (default, any auth, zero app change):** sign in
  once by hand, `await context.storageState({ path: 'auth-state.json' })`,
  gitignore that file (it holds a live session cookie), then the recorder uses
  `newContext({ storageState: 'auth-state.json' })` and skips sign-in entirely.
  Re-capture when the session TTL lapses.
- **An existing test-auth / dev-login endpoint:** if the app already mints a
  session behind a secret (common in starter kits), drive that headlessly.
  Fully automated — but never reassign real data to a test user if its cleanup
  cascades; read-only / shared views are safe.

Also set `STEPS` in `record-tour.mjs` to the app's step count (it's hardcoded).

- `record-tour.mjs` — records the tour headless: LOSSLESS PNG frames via
  CDP screencast (timestamps included), then ffmpeg assembles and muxes the
  ORIGINAL MP3s at offsets measured by patching `Audio.play` in-page.
  Two capture paths that DON'T work — don't re-derive them: Playwright's
  `recordVideo` (its adaptive encoder makes the WHOLE page shimmer/blink
  several times a second), and getDisplayMedia tab-capture (needs a human
  picking the tab; a window pick records silent video).
- `record-demo.mjs` — feature demos: segments of `{ say, do?, delayMs? }`.
  Narration cached by text-hash (iterating on actions is free); each
  action fires at its narration offset — the voice says "type the
  address…" while the harness types. Write actions with ROLE-BASED
  locators (`getByRole('button', { name: … })`, not CSS) — the demo then
  only renders when the markup carries real roles and names, making every
  demo an accessibility regression test for free. Other viewports are one line
  (390×844 → 9:16 Shorts). The same specs yield GIF slices and
  reproducible screenshots for written guides.

**Make the change visible (earned on WPTutor, 2026-06):** a static screenshot of a *small* change
reads poorly — the eye can't find what moved. For any step where something changes, prefer a **GIF**
(motion shows it) and **crop the GIF to the action region** so the change FILLS the frame instead of
being a few pixels in a busy UI. And the subtle one: **navigation/click steps need the recorder to
hover-and-pause on the target before clicking** — otherwise the click fires straight into a page load
and there's no clean, croppable moment of "here's what you press". Edit steps show the *change*; nav
steps must show the *click*.

**Give the click a visible cursor — and inject it into the target's own frame (WPTutor, 2026-06-17):**
headless screencast renders **no mouse pointer**, so even a hover-pause shows nothing pointing at the
target. Inject a synthetic ring + arrow cursor on the element, hold a beat, pulse, then act — now the
"press here" moment is real. The trap that cost the most: **a top-document overlay gets painted OVER by
any content `<iframe>`** (an Elementor/canvas preview), losing the stacking war even at max z-index
(`elementFromPoint` returns the iframe). Inject the overlay into **the same frame/document as the
target** and position it from that element's frame-local `getBoundingClientRect`. Then **record the
target's viewport box and auto-crop the GIF to it** — the crop follows the action wherever it lands, so
you stop guessing fixed crop regions per step. For nav steps, ring the *real source link* on the current
page, then navigate. Caveat: actions fire on a **fixed narration clock**, so a slow SPA load drifts the
visual behind the audio (re-record for a fast load); full-width targets ring as a wide band, not a
spotlight — compact targets read best.

**Two more GIF lessons (WPTutor, 2026-06-18):** (1) **A looping GIF gives no sense of where it starts** —
bake a thin progress bar along the bottom that sweeps left→right and snaps back at the loop, so the viewer
can tell the beginning. Slow playback a touch (`setpts 1.3`) and sample more frames (≈12fps) so the action
is followable, not a blur. (2) **Don't let a loading splash fill a step's GIF** — when the app is still
loading as the step's narration fires, the GIF catches the spinner. Stamp the video-time the ring actually
drew (it only fires once the target is ready) and **anchor the GIF's start on that**, not the narration
offset, so the pre-ring loading frames fall outside the clip; a min-duration covers the case where the load
drifted the action so late its window collapsed.

Verify by inspection, not by exit code: extract frames at known offsets
(`ffmpeg -ss N -frames:v 1`) and LOOK at them — right page, spotlight/action
visible; `volumedetect` a narration window to confirm audio landed. Then
ship the promo MP4 in the app's static assets with a `<video controls>`
player on its how-it-works page.

When a clip is **proof of a fix** rather than promo (a reviewer or client will
trust it), gate it: before sharing, run a **blind audit** — an independent
agent, told only the claim, samples frames and must find the pixels that show
the whole claim or it FAILs. Trust the clip only when the journey shows
precondition→action→outcome, the harness asserts the real state, AND the blind
audit confirms the pixels. Full pattern + the proven weaknesses in
`docs/pattern.md` → "Gate the proof".

## When you're done

- Add the app to the Adopters list in `docs/pattern.md`, with anything new
  you learned (gotchas earned there compound across every future adopter).
  **This is a PUBLIC repo — anonymise.** Contribute the generalisable pattern
  only; never commit project/client specifics (client or company names,
  internal product names, hostnames/URLs, account/zone IDs, tokens, secrets,
  real emails, file paths, schema/table names). Describe the app by its shape
  ("a Cloudflare Workers BI app", "a private healthcare client", "a shadcn
  host"), as the Adopters list already does. The lesson is the gift; the
  client's identity is not yours to share.
- Tell the owner the one-command re-record story: app changed → re-run the
  script → fresh video. Stale demos are now a choice.

