DocsTours
Docs

Tours

The marker contract: data-rendemo, data-rendemo-do, data-rendemo-wait, the generated rendemo.tours.json lockfile, previewing a draft against your own app, and what tours do not do.

The marker contract

A tour is a plan with no recording, whose steps point at elements in your own source instead of at replay footage. It can be authored, previewed before it is live, published, statically verified, and rendered to a visitor inside your product.

Markers are generated, never hand-written

You do not write data-rendemo attributes by guessing at them. A marker is placed one of two ways:

  • From a recording. Record yourself clicking through the tour; the agent matches each captured click back to source — via React dev-build _debugSource first, then data-testid, id, name, href, or visible text, in that order of confidence — and writes the attribute onto the matching element as a reviewable diff.
  • From the code alone, via the MCP tools below: you tell the agent what step targets what route, and it hands you the exact attribute string to place.

An unplaced beat is the safer failure

The moment the generator cannot place a click confidently, it says so instead of guessing. If a capture’s evidence does not match anything in source — a stale line number because the file changed since recording, no matching id, testid or text at all — that beat comes back as unplaced, with the reason and everything that was tried. A near-match placed silently would be a marker pointing at the wrong thing forever.

The format

AttributeMeaning
data-rendemo"<tour>/<step>" — the wire format for a step marker. Each half is a lowercase slug (letters, digits, hyphens; no leading or trailing hyphen). <tour> is the tour’s tourSlug, chosen when the tour is created, and publishing writes it to the demo row verbatim — never a slug derived from the project’s name — so the markers you commit and the published tour agree by construction.
data-rendemo-doOptional. "click" | "type" | "hover" — what completes the step. Omitted means the step waits for an explicit Next.
data-rendemo-waitOptional. "<selector>" — a selector that must exist before the step is reachable.
jsx
<button data-rendemo="onboarding/new-project" data-rendemo-do="click">
  New project
</button>

There is also a zero-runtime helper that spreads the same attributes onto an element. It is deliberately not a wrapper component — a wrapper would inject a layout box into your DOM and fight your CSS — and it throws if the id is not a valid <tour>/<step> pair rather than emitting a broken attribute:

jsx
<button {...demo("onboarding/new-project", { do: "click" })}>New project</button>
If another artifact in the workspace already holds a tour’s slug, publish fails with slug_taken rather than renaming the tour. Renaming would silently orphan every marker already in your repo.

The MCP tools

The MCP cannot touch your filesystem, so these tools never write a marker or a lockfile themselves — each returns the exact text to write, and the agent (or you) writes it and commits it. The natural order is author → preview → publish: publishing is no longer how you find out whether a tour is right.

  • rendemo_create_tour — creates a tour project with a chosen tourSlug. Every marker for the tour starts <tourSlug>/.
  • rendemo_add_tour_step — appends a step (route, optional do, wait, match, title, blurb) and returns the exact data-rendemo attribute to place.
  • rendemo_get_tour_preview — a link that runs the draft on your own running app. See previewing a draft.
  • rendemo_get_tour_lockfile — for a published tour only: the rendemo.tours.json contents to commit.
  • The second visit. rendemo_list_tours is the entry point — every tour with its project id, slug, step count and whether it is live. Rewording is rendemo_update_step; reordering is rendemo_reorder_steps, which touches no source, because the markers say where and the order says when.
  • rendemo_remove_tour_step deletes one step and returns the attribute to strip from source — leaving the marker behind makes rendemo check fail the build with orphan-marker, so the two edits belong in one diff. It refuses when another step’s branch choice points at the one being removed, and when it is the last step.
  • rendemo_remove_tour retires the whole thing in one change: offline, every marker to strip, and the lockfile with only that entry removed. Neither deletes anything — the published plan is kept, so publishing again serves the identical tour.

A tour honours a subset of the step surface. rendemo_update_step, rendemo_style_steps and rendemo_set_card_identity accept the full player vocabulary, but the payload carries only what the card layer draws: per step title, blurb, eyebrow, advanceLabel, successMessage, recoveryHint, emphasis, emphasisColor and choices, plus the demo-wide look and whether the step indicator is hidden. Fields like variant, size, placement, anim, choreography, narration and the guide persona’s name and avatar are saved and never rendered — those tools now say which fields they dropped rather than confirming a change no viewer will see.

Publishing a tour

Publishing a tour does only what a tour needs: it validates every step’s target, hashes the plan, and writes the tour’s row. It builds no artifact — no HTML variants, no upload, no poster, no export manifest, no knowledge index, no A/B promotion — because a tour has no footage for any of that to come from. A locale-pinned tour publish is refused rather than silently ignoring the locale.

The response carries no viewer URL, because a tour has no page of its own to open. It renders inside your product, so its “URL” is the element you place there. /d/<demoId> and the workspace short-link still 404 for a tour; what a published tour serves is its payload at /d/<demoId>/tour and /site/<workspace>/<slug>/tour. A tour is also not visible in the studio, which requires a capture — publish it and verify it from the agent and CI.

rendemo.tours.json

Generated and committed to your repo root, never hand-edited. It describes the published tour, not a draft, which is what lets rendemo check run offline, with no token, in CI with no network access.

rendemo.tours.json
{
  "version": 1,
  "tours": {
    "onboarding": {
      "planHash": "sha256:…",
      "steps": [
        { "step": "new-project", "route": "/projects", "do": "click" },
        { "step": "name-it", "route": "/projects/new" }
      ]
    }
  },
  "ignore": ["fixtures/", "*.stories.tsx"]
}
  • One file describes every tour in the repo. rendemo_get_tour_lockfile takes the current file as an optional lockfile argument and returns the merged result, so regenerating one tour keeps the others. Always pass it if the file exists — writing a single-tour file would leave the other tours’ markers in source with nothing checking them.
  • planHash is the value publish stores. Check prints it and cannot verify it: check is offline by design, so it has no way to ask whether that is still the published plan. Comparing them is the studio’s job (“Changes not live”). Treat the printed hash as something a human can diff, not as staleness detection — there is no staleness detection in check today.
  • Each step’s route is the page the step expects to be on. do is present only when the step needs one.
  • match"first", "last", or { "nth": <number> } — is present only when a step’s marker is expected to resolve to more than one element. nth is one-based, so { "nth": 1 } means the same element "first" does. A tour treats an out-of-range nth as no match at all — the step waits, then gives up visibly — rather than clamping to an element the author did not name. Author it with rendemo_add_tour_step’s match argument, never by editing this file.

match is about DOM multiplicity; check only sees SOURCE multiplicity

The two disagree in exactly one direction, and it matters.

A marker on two mutually exclusive JSX branches — a “Publish” control and the “Published” one that replaces it — is two occurrences in source and one element at runtime. check reports duplicate-marker until the step has a match; "first" is correct, because whichever branch renders is the only hit.

A marker inside a .map() is one occurrence in source and N elements at runtime. check cannot see that at all, and passes. The tour then requires exactly one hit, finds N, and treats the step as unresolved — a green build and a tour that gives up. Nothing static can catch this; deciding a looped target needs match is the author’s job.

The ignore list

ignore is optional path patterns that are not shipped source. When absent, these defaults apply:

defaults
*.test.*   *.spec.*   __tests__/   e2e/   tests/

A spec that queries a marker is not a second definition of the element (that produced a bogus duplicate-marker), and a marker that exists only in a spec must not count as present (that let the real element be deleted with CI still green). Providing ignore replaces the defaults — include the test patterns yourself if you still want them. An empty array means “check everything”.

PatternMatches
tests/that directory at any depth
*.test.*a filename glob (basename only)
src/legacya path prefix, or an exact path
src/**/*.gen.ts** crosses directories, * does not

Matched case-sensitively.

The lockfile is also read by an older filename. See the vocabulary for which old spellings still work, and the CLI page for the one direction the fallback does not run in.

Running a tour

A published tour runs in your own product with the same two lines an embed uses:

html
<script src="https://www.rendemo.com/embed.js" async></script>
<rendemo-demo demo="acme/onboarding" mode="tour"></rendemo-demo>

Three attributes are optional, and absent means exactly the behaviour above:

html
<rendemo-demo demo="acme/onboarding" mode="tour"
              user="u_123"                  <!-- opt-in identity: progress + analytics per person -->
              routes="/app,/app/projects"   <!-- the pages this may run on at all -->
              when="always"                 <!-- replay a finished tour; default "once" -->
></rendemo-demo>

Put it in the layout or route that owns the tour’s entry route, and keep it mounted across the pages the steps span — unmounting the element tears the tour down.

Mounting is starting

Unlike modal mode, tour mode has no open attribute and no start button: the tour begins in connectedCallback. A host that controls when the tour runs expresses that by rendering the element or not — and must finish any pre-flight work before the element is in the tree, not in an effect that runs after the commit.

What it does

  • Anchoring. Each step resolves [data-rendemo="<tour>/<step>"] in the live document. With no match, exactly one hit is required — the same rule check enforces statically — and an ambiguous marker is treated as a missing target rather than guessed at.
  • A visible match wins. Resolution is querySelectorAll, then the hits are filtered to the ones actually on screen, and only then does match choose among them. So a marker on both a hidden drawer copy and a visible toolbar button resolves to the visible one with no match needed, and a hidden duplicate does not shift what "last" counts. The test is whether the element renders a box at all, not how big it is, so a legitimately 0×0 target — an icon button drawn by ::before, an SVG with no intrinsic size — still resolves.
  • Rect tracking. The card and emphasis re-measure on scroll (captured, so scrolling inside an overflow container counts), on resize, and on DOM mutation, coalesced through one animation frame. Placement flips sides when a card would clip the viewport.
  • Host-page isolation. One position:fixed layer appended to <body>, with the card in a shadow root. Nothing of the tour’s CSS reaches your page and none of your CSS reaches the card. The layer is pointer-events: none except the card itself, and document.body scroll is never locked.
  • Advance. With data-rendemo-do, the step completes when the viewer does the real thing. The listener never calls preventDefault() and never stops propagation, so your own handler runs exactly as it would with no tour on the page. Absent do, the card shows an explicit Continue (or the step’s advanceLabel, or Finish on the last step).
  • Waiting. A target not in the DOM yet — a modal, a lazy route, an empty state — is waited for with a MutationObserver, card hidden, for up to 8 seconds. data-rendemo-wait adds a precondition that must exist first. On timeout the wait ends visibly: a centred card with the step’s recoveryHint (or generic copy), Skip this step, and Close. It never hangs silently and it never re-anchors to a near-match.
  • Routing. Starting from the resume point, the tour shows the first step whose route applies to the current page; a step whose route does not apply is skipped. When no step applies, the tour is idle and invisible until a navigation. pushState, replaceState, popstate and hashchange are all followed; a hard reload is covered by progress. A floor stops a / route claiming every page.
  • Progress. localStorage, keyed rendemo.guide.v1.<tour> — or rendemo.guide.v1.<tour>:<user> when the element carries a user — holding the current step id plus done / dismissed state. If storage is unavailable (a partitioned third-party context) the tour still runs; it just does not resume.
  • Branching. A step may carry up to three choices, each a button that jumps to another step id — forwards, skipping the steps between, or backwards for a “show me that again”. Afterwards the tour advances linearly from wherever the branch landed, and progress records the chosen path so a reload resumes on it. A choice pointing at a step the payload is not serving is dropped when the payload is built.
  • On a phone. Below 720px wide, or on a coarse pointer under 560px tall, the card stops being an anchored tooltip and becomes a viewport-level bottom sheet: full-width inside 10px gutters, above the home indicator, with its own scroll and no arrow. The emphasis ring keeps tracking the live element, so the target is still indicated — the sheet explains it rather than pointing at it, because a 340px card cannot sit beside anything on a 390px screen. visualViewport resize and scroll are tracked too, so the software keyboard opening or the URL bar collapsing re-measures.
  • Accessibility. Each step is announced through a polite role="status" live region inside the shadow root. Focus is never stolen — the tour is non-modal, and grabbing focus would break the interaction it is coaching. The card is a labelled role="group" with aria-roledescription="Product tour step" and its controls are in the tab order. Every animation honours prefers-reduced-motion.
  • Dismissal. The card’s × or Escape closes it, records dismissed, and it stays closed on every later load. when="always" is the supported way to restart.

Analytics and identity

A tour beacons to the same ingest a published replay demo uses, with the same event names, so a tour’s funnel appears in that demo’s own analytics with nothing new to configure: demo_loaded (on the first card actually shown, not on mount, so a parked tour is not counted as a view), step_shown, step_completed, step_dwell and demo_completed. Transport is sendBeacon, so a step completion survives the navigation the step itself caused. A give-up reports its dwell with reason: "unresolved" and no step_completed.

Do-Not-Track is the only privacy posture observed

There is no consent API in this path. If you have a consent regime to satisfy, gate the element itself.

user="u_123" scopes progress to that id and sends it with every analytics event, so two people sharing a browser no longer share one position. The string is entirely yours — Rendemo never resolves it to anybody, nothing else about the viewer is collected, and it is only ever a storage-key suffix and a JSON field, bounded at 64 characters. Absent, behaviour is byte-for-byte what it was, which is what an anonymous marketing page needs.

When it cannot run

Every resolution failure is loud: a console.error naming the reason, and a short visible note in the element (“This product tour could not be loaded.”). That covers a demo attribute that resolves to nothing, a payload route that refuses, and a payload with no steps. The one quiet outcome is a viewer who has already finished or dismissed this tour — nothing renders, by design.

The payload route is public and CORS-open, because a tour runs cross-origin inside your product, and it is gated by exactly the rule the demo page and the poster use: published, not password-protected, not expired. A password-protected tour is refused rather than prompted — there is no document to render a form into. The tour runtime itself is a second script, /embed-tour.js, fetched on demand by mode="tour" only, so pages that embed a replay demo do not pay for it.

Previewing a draft

Publishing used to be the only way to see a tour, which is backwards: you had to make it live to find out whether it was right. Ask the agent for a preview link instead.

mcp
rendemo_get_tour_preview({ projectId, baseUrl? })
  → http://localhost:3000/projects?rendemo_preview=<token>

Open that URL in a browser pointed at your own running app and the draft runs, exactly as the published tour would, with a persistent PREVIEW — DRAFT, NOT LIVE badge on every card.

There is no code change. The data-rendemo markers and the <rendemo-demo … mode="tour"> element are already in your source — that is what you are previewing — so the only difference between a preview and the real thing is the query parameter in the address bar. Deliberately not an attribute: an attribute would have to be added and then removed, and a preview token left in committed source is a capability checked into a repo.

baseUrl is where your app is running and defaults to http://localhost:3000. Pass a staging or production origin to preview there instead.

What a preview is
Lifetime30 minutes from issue. Long enough to walk a nine-step tour on a real product, fix a step and reload; short enough that the link is dead by the time it turns up in a shell history, a CI log or an agent transcript.
Who can open itAnyone holding the link. It is a bearer token — no sign-in, no workspace membership — because it has to work in a browser on the machine running your app with no auth ceremony. Treat it like a password. It names one project only.
Cachingprivate, no-store, never at a CDN. It is a draft behind a bearer token, and a cached copy would also keep showing an old draft after you changed it.
AnalyticsNone. The preview payload carries no project id for the ingest to attribute a batch to, and the runtime refuses to start its tracker on the preview flag. You clicking your own draft eleven times is not eleven views.
ProgressKept under rendemo.guide.v1.preview.<tour>, never the real key. So previewing cannot leave a done state in a real viewer’s slot, and previewing a change to a tour you have already finished in this browser still shows it to you.
Publish stateUnchanged. Preview publishes nothing and creates no row. A tour that has never been published previews fine — which is the case it matters most for.

The markers must be in the build you point at

A preview sends the draft plan to a browser. It cannot send the draft markup — that lives in your application, not Rendemo’s. So every step anchors to a data-rendemo attribute that has to already exist in whatever build is answering at baseUrl.

On a dev server that is immediate: save the file, reload, the step anchors. Against staging or production, the commit that adds the markers has to be deployed there first — otherwise every step reports that it cannot find its target, a real failure correctly reported with a cause that has nothing to do with the tour.

The usual order, therefore: write the markers, preview against your dev server, deploy the markers with your normal release, then publish the tour.

Which gates a preview token lifts

Exactly the gates that are statements about a published artifact’s audience, because a preview has no audience beyond the person holding the token: the publish gate — the whole point — and the password and expiry gates.

It does not lift the workspace kill switch. An operator who turned tours off for the workspace was not saying “except drafts”: that switch is thrown when a release has moved the product’s UI out from under every tour’s markers at once, and a preview anchors to exactly those markers.

The kill switch

Two switches, neither of which deletes anything — turning either back on serves the identical artifact.

  • Per demo. rendemo_take_demo_offline (or Take offline in the library’s ⋯ menu) flips the published status off, and every public surface already refuses a non-published row: the payload route stops serving, the poster and the public link go dark, and the tour on your product renders nothing.
  • Per workspace. Settings → Product tours refuses every tour payload in the workspace at once — what you reach for when a release has moved the UI out from under every tour’s markers together. Refusals are no-store, so turning it back on is immediate.

Propagation is bounded at 30 seconds: the payload is served public, max-age=0, s-maxage=30. max-age=0 deliberately keeps it out of private caches, which are the one copy no purge can reach, so the shared-cache window is the worst case. There is no stale-while-revalidate.

What it is not: a way to reach a tab where the tour is already running. That visitor finishes the tour. This is a “stop serving it” switch.

What tours do not do

Read this before you roll one out to real users. These are current absences, stated plainly rather than softened.

  • No targeting rule engine. No “new users only”, no per-plan or per-role condition, no feature flag, no percentage rollout. routes filters pages; conditionally rendering the element is how you filter people, and that is the honest primitive rather than a gap.
  • No cross-device progress. user makes progress per person within a browser — it is localStorage, not a server-side profile. Clearing storage or switching device still restarts the tour, and there is no identify() call, no profile store, and no server-side completion record beyond the analytics rows.
  • No privacy posture beyond Do-Not-Track. The analytics honour DNT and nothing else.
  • No rule-evaluated branching. Branches are viewer-chosen buttons the author declared. Nothing evaluates a condition to pick a path, and a step whose target never appears gives up visibly rather than branching.
  • No tour surface for hotspot jumps. A hotspot needs a rectangle over recorded footage to place itself on; a tour has no footage, so card choices are the whole branch surface here.
  • No lead capture, no localization, no replay artifact. A tour has no HTML, poster, export or locale build, and it renders no in-card form — so rendemo:lead never fires for a tour.

The offline gate has limits of its own — three things check cannot tell you. See what check verifies.