Guided Tour design ruleset¶
A checklist for writing a new mini-tour under frontends/admin/src/tour/.
It restates and expands on the "Adding a new mini-tour" section of the root
CLAUDE.md — read that first for the full mechanics
(file layout, TourDefinition, the content package). This page is the
rule-by-rule checklist to apply while writing one, with real examples
pulled from the existing tours (contentTypes.ts, createEditContent.ts,
layoutUsage.ts).
1. Placement¶
- Category is about who, not what page.
category: 'user'= everyday content/screen work a daily editor does.category: 'admin'= setup tasks — layouts, content types, designs. If a tour mixes both (e.g. it starts on an admin page but teaches a content-editor workflow), classify by the audience the tour is for, not the first route it visits. - One
TourDefinitionper file intours/, registered intours/index.ts'sTOURSarray. Don't bundle unrelated workflows into one tour just to avoid a new file — a tour should teach one coherent task end to end.
2. Targeting elements¶
- Default to a dedicated
data-tour="..."attribute, added directly on the element in its view/component. Never target an existing CSS class or DOM structure on the assumption it'll stay put — a redesign or refactor changing it breaks the tour silently (no compile error, just a step that times out and skips itself at runtime). - The only exception is a class that is already a deliberate, load-bearing
structural hook rather than incidental styling — e.g.
.content-edit-preview,.tag-fields-section,.action-buttons. Use judgement, but when in doubt, add the attribute instead. - Name
data-tourvalues predictably: static UI chrome gets a flat name (content-new,layouts-table,contenttypes-filter); anything row/card-level still carries its row's id (`contenttype-card-${ct.id}`) for debugging/uniqueness, but a tour step must never match it by that specific id — see §3. - If a step's
before()hook needs to flip a class or open a<details>for the duration of that step only (not its neighbors), undo it on the way out rather than leaving it set — seecreateEditContent.ts'ssetPreviewElevated, which is toggled off in the neighboring steps' ownbefore()rather than left on.
3. No dependency on specific database rows¶
- Never hardcode a specific row's ID or name in a step's
routeorselector(a particular content type, a particular content element). A tour must work against whatever's actually in the database, including one that's completely empty — there is no bundled tour-content package to rely on having been imported first. - Where a step needs to show some real example of a repeated kind (a content type card, a table row), target it generically rather than by identity:
- A prefix-matched attribute selector picks whichever element matches
first in the DOM — e.g.
[data-tour^="contenttype-card-"]instead of[data-tour="contenttype-card-${specificId}"]. SeecreateEditContentTour's "Pick a content type" step. - A plain, repeated
data-touron a per-row element (e.g. a table row's action buttons) already behaves this way for free —document.querySelectorreturns the first match regardless of which row it belongs to. - Follow the real in-app navigation instead of forcing a step's own
route. Once a step'sadvanceOnClickhas taken the person to wherever the real click actually leads (a newly-created item's own ID, whatever row they picked), later steps should just keep targeting elements on that page rather than specifyingrouteagain. Arouteon a step that follows a real navigating click is usually a sign the step is compensating for a specific row it's assuming exists —createEditContentTourdemonstrates the alternative: after picking any content type card, every remaining step omitsrouteentirely. - If no row of the needed kind exists, the step's selector simply won't
resolve and the existing
waitForElementtimeout-and-skip behavior (runner.ts) skips it — a thinner database just means a few steps don't show, not a broken tour. This is the intended, graceful degradation path; don't special-case it.
4. Writing steps¶
- One idea per step. A step's
titleis a short label;descriptionis one or two sentences explaining what the thing does or why it matters — not a restatement of the title, not a paragraph. - Prefer showing the real consequence of an action over describing it abstractly — e.g. "Save writes immediately... every screen this content is currently assigned to updates right away" rather than just "Save your changes."
- Pick
sidebased on where the popover will actually have room to render (table views commonly usetopfor a table andbottomfor a header action; a right-hand preview panel usesleft). Check it against the real page, not just a guess. - Order steps the way a first-time user would actually move through the task: overview of the page first, then the primary action (e.g. "create new"), then supporting details.
5. Navigation and advanceOnClick¶
- Set
advanceOnClick: trueon any step whose highlighted element performs real navigation — a route change, or a click that swaps out what's on screen (e.g. closing a "pick one" dialog into a form). Without it, clicking the actual element (the natural thing to do instead of the tour's own "Next") leaves the popover stuck pointing at wherever that element used to be. - Leave it unset for purely observational steps — a preview panel, a field to read, anything where clicking the target isn't the point.
- Never set it on a tour's last step. "Advance" there means "stop the
tour and navigate to
/tour" (seerunner.ts'sexitToTourPage), which would yank the person away from the page their click just took them to — undoing the very thing they were shown.layoutUsageTour's last step (an Edit button navigating to the layout editor) is deliberately left withoutadvanceOnClickfor exactly this reason. - If a navigating action genuinely belongs at the end of a tour, add a
closing, non-navigating informational step after it instead of putting
advanceOnClickon the last step. - An
advanceOnClickstep whose click only opens/closes a same-route dialog (not a route change) needs a defensivebefore()on the step after it, re-running that same state change. If the person clicks "Next" instead of the real element, a route-based step's old page is genuinely gone, so the next step's target is missing and skips via the normal timeout — safe by construction. A dialog is different: the page underneath it is already mounted either way, so the following step's target already exists in the DOM and resolves immediately, highlighting it behind the dialog that never opened/closed. Fix it by clicking the real element again in abefore()hook — it's a no-op once the dialog's already in the right state. SeeensureContentTypeSelectedincreateEditContent.tsandcloseDialogIfStillOpenincontentTypes.ts. runner.tsskips an unresolvable step in whichever direction the person was already travelling (Next vs. Previous), not always forward — this is what makes Previous work correctly back through dialog-dependent steps. You don't need to do anything for this yourself; it's automatic as long as you don't callgoToStepdirectly from a tour file.
6. Scope and coherence¶
- A tour needs one connecting topic. Every step should serve the single
task or concept named in the tour's
title/description— if a step only makes sense as "also, while we're here," it belongs in a different tour (or doesn't belong at all). - Target 7–15 steps. Fewer than 7 usually means the topic is too thin to be its own tour (fold it into a related one, or add the missing steps); more than 15 usually means it's actually two tours glued together — split it along the topic boundary instead of trimming description text to compensate.
- Don't re-teach a concept a step already introduced. If an earlier step (in this tour, or a prerequisite tour) already explained what something is (e.g. "Live Preview," "Scheduling"), a later step reusing it should use it, not re-explain it — repeating the same explanation reads as padding and signals the step doesn't have its own reason to exist.
- Every step needs something for the person to do or verify, not just read — click a button, fill a field, open a panel, compare what changed. A step that only narrates ("this section exists, it does X") without giving the person an action or a concrete thing to look at and confirm is a sign the step should be merged into a neighboring one rather than standing alone.
7. Verifying a new tour¶
Before registering it:
- Walk through the tour manually in the admin UI, including once against a genuinely empty database — not only your already-populated dev one — to confirm the steps that need a real example row degrade gracefully (skip) rather than hang or error.
- Confirm every step's element resolves within
ELEMENT_WAIT_MS(4s) — if a step silently skips, its selector or hardcoded route/id is wrong. - Click (don't just "Next") through every
advanceOnClickstep to confirm the popover follows correctly rather than desyncing. - Confirm the last step is not
advanceOnClick, and that "Exit Tour" lands back on/tour. - Click only "Next" from the first step to the last, never the real
elements — confirm you reach "Exit Tour". This is a hard rule, not
optional: a tour that only looks fine because a dialog-gated step
skips cleanly can still silently die off the end of the array if the
skip cascades all the way past the last step (see §5's closing point
and
CLAUDE.mdpoint 8) —stop()runs with no navigation back to/tour, and nothing in the UI tells you the tour ended early. A cleanly-skipping middle step and a tour that's about to die this way look identical until you actually reach the end, so this check can't be skipped on the assumption that skipping "looked graceful" earlier. - Then click "Previous" from that last step all the way back to the
first, confirming each one lands on the step actually before it, not
stuck or jumping forward. This is also a hard rule — the direction-
preserving skip in
runner.ts(point 7) makes this correct by construction, but a new tour is still what exercises it, so verify it rather than assuming it. - If the tour's styling needed any override in
main.css, follow Styleguide: Third-party widgets mounted outside the component tree and its dark-mode rules.