The guide JSON, field by field
This is the reference for everything that can appear in a guide. For a gentle start, read authoring.md first; to test what you wrote, see checking.md.
Every field below is tagged one of two ways:
- (playback) — the helper engine acts on it. Get it wrong and users see it.
- (notes) — written for humans (and the AI that drafts guides): audit trail, caveats, reminders. Playback ignores it — along with any other key it doesn't recognize, so extra notes ride along harmlessly.
One safety promise up front: a malformed guide degrades to "this guide is absent" on the page, never to an error on your app.
The guide (top level)
{
"slug": "approve-a-document",
"title": "Review & approve a submitted document",
"genre": "walkthrough",
"spans": [ ... ],
"steps": [ ... ]
}
slug(required, playback) — the guide's stable machine name, kebab-case. Completion tracking and draft-vs-published resolution key off it.title(required, playback) — the human line in the helper dot's menu.genre(optional, playback, defaultwalkthrough) —walkthrough(do a real task) ortour(look around a page). Decides the menu grouping and the playback style. Tours only read each step'skey,title,instruction, andanchor— none of the flow machinery below applies to them.spans(required, playback) — which pages (and code areas) the guide covers. See the next section.steps(required, playback) — the steps themselves.workflow_summary(optional, notes) — long prose describing the whole workflow as traced from the code. The Studio displays it; playback never does.honest_limits(optional, notes) — caveats the author couldn't verify; playback never shows it. A list of plain-language notes: what the author could not confirm, selectors that are guesses, behavior that depends on live data. Keep it; it is the guide's conscience.schema_version(optional, notes) — a format stamp in the fixture files (1today). Nothing reads it yet.
Two more fields live on the database row (the splain_guides table), not in
the JSON you write: status (draft or published, default draft) and
version (an integer, default 1, part of the completion-tracking key). Draft
guides are hidden from regular users by default (serve_drafts ships false);
gate-passers preview them in place. There is also a stored pii_default flag
on the row; nothing acts on it today.
Spans: the pages a guide covers
A span names one place the guide touches:
{ "resource": "DocumentResource (document review queue)",
"role_in_workflow": "The review queue table ...",
"route": "/admin/documents" }
resource(required, playback) — the span's name; the exact string steps point at viaspan_ref.route(playback) — the URL path of that page. A{placeholder}segment matches any single real value:/admin/documents/{record}covers/admin/documents/7but not the list page. Matching is exact-or-prefix on whole segments —/admin/documentsalso covers/admin/documents/3/edit, never/admin/documents-archive.- A span may have
"route": null— that marks a service or background code area kept as context. Steps must never attach to a route-less span; the checker calls that an error because such steps can't play on any page. role_in_workflow(optional, notes) — what this piece does in the flow.
Steps
splain:check only fails if key, span_ref, or anchor are missing; a useful
step also needs order, title, and instruction (they default to blank, which
plays but says nothing).
order(playback) — display position, an integer. Steps play in ascending order; the checker warns unless the orders form a clean 1, 2, 3, … sequence.kind(playback, defaultinstruction) — one of:instruction— explain or point; the ordinary step.navigation— "go to this page"; ends one page's portion and hands off to the next (see playback.md). Requiresdestination.decision— a real fork (Accept / Reject / Skip). Requiresoptions.
key(required, playback) — the step's stable name; must be unique in the guide. Everything that routes (next, option branches) points at keys.title,instruction(playback) — the helper bubble's heading and body.instructionsupports a small, safe Markdown subset (**bold**,`code`,-bullet lists, line breaks; a blank line is a paragraph gap). It is HTML-escaped first, so any tag-like text renders as literal characters, never markup.titleand labels are always plain text. See authoring.md.span_ref(required, playback) — theresourcestring of a routed span; it decides which page the step plays on.anchor(required, playback) — what gets spotlighted. Own section below.playback(playback, defaultdirect) —direct(the anchor is on the page) oropen-modal-then-anchor(the anchor only exists once a window the previous step opens is open; the engine waits for it to appear). A tour stops at the first step that isn't a direct instruction — the flow steps below belong to walkthroughs.modal_scoped(optional, notes-ish) — true when the step's screen is a modal. It is serialized, but the engine gates onplayback, not this — a step can be modal-scoped yet playdirect(its anchor, a row button, sits on the base page).next(optional, playback) — the key of the step that follows; absent means "next in display order". Anextpointing at no step, at itself, or forming a loop is a checker error.popover_side(optional, playback) — pin the bubble totop,right,bottom, orleftof the anchor, for when auto-placement would cover the control the user must reach. Anything else falls back to auto (checker warns).destination(navigation steps, required, playback) — the path the user should end up on,{placeholder}s allowed. If the user is already there, the step is skipped as already satisfied.advance_on(optional, playback) — turns the step into an action step: no Next button; the walkthrough advances when the user clicks this real control and the step's anchor then leaves the screen (a dialog that stays open failed validation — the user is still on the step). Shape mirrors an anchor:{ "selector": "...", "fallback_selectors": [...] }. Honest scope reminder: this is a real click on live data — nothing is simulated.reveal(optional, playback) — an ordered list of trigger selectors the engine clicks (waiting for each) before it looks for this step's anchor, for an anchor that only exists once a tab, a dropdown, or a modal is opened. Walkthrough-only. Handles Livewire and Alpine tabs, teleported dropdowns, and modals, nesting included; the chain is idempotent (a panel that's already open is skipped, not toggled shut). This is the robust, self-driving way to reach a gated anchor — prefer it toadvance: await-anchorfor dropdowns and modals. See authoring.md.advance(optional, playback) — only the value"await-anchor"means anything: it parks the step until the next step's anchor appears after a real click. Kept for genuine learn-by-doing gates; for a dropdown or modal prefer arevealchain (above), which drives the panel open itself instead of waiting on the user — anawait-anchorgate with nothing to open the panel simply stalls.needs_review(optional, notes + gate) — a list of plain-language reasons a human still needs to verify this step on a live screen. Each reason is a checker warning, sosplain:check --strictfails until someone resolves them. The free path is resolving eachneeds_reviewby hand and re-runningsplain:check --strict; the Studio's review queue (splain/pro, the separate proprietary package) is a convenience over that same discipline.pii_risk(mixed) — the step's sensitive-data record. Own section below.- Everything else is (notes):
screen(which screen the step lives on),status_effect(what really changes in the database — shown to users only on decision options, see below),confidence/confidence_reason,source_refs(host files the claim was read from),provenance(route,component_fqcn,symbols— where in the code this came from), and any ad-hoc flags likecross_resource_hop.
Anchors: pointing at the right thing
"anchor": {
"selector": "[data-splain=\"start-review\"]",
"component_name": "the Start Review button",
"fallback_selectors": ["[data-splain=\"row-review\"]"],
"needs_injected_attr": true,
"recommended_attr": "->extraAttributes(['data-splain' => 'start-review']) on the start_approval header Action"
}
selector(required, playback) — machine code: the CSS selector of the element to spotlight. Free authors write these by hand (withsplain:introspectandsplain:suggestto surface the right markers); having the Studio pick them for you is a convenience in splain/pro (the separate proprietary package).component_name(recommended, playback) — the human name of that element ("the Start Review button"). The Studio shows this instead of the selector.fallback_selectors(optional, playback) — tried in order when the main selector matches nothing on the page.component_type(notes) — what sort of widget it is, for the reader.needs_injected_attr(notes) — true when the selector relies on adata-splainmarker your app's code must carry for it to work.recommended_attr(notes) — the exact one-liner to paste into your app to add that marker.
Naming data-splain markers: kebab-case, {thing} or {thing}-{role} —
start-review, doc-accept, branch-wallet-section. Name the thing the way
a human would say it.
Decision options
A decision step lists the real buttons the user can choose between:
"options": [
{ "label": "Accept",
"anchor": { "selector": "[data-splain=\"doc-accept\"]" },
"status_effect": "IN_REVIEW -> ACCEPTED; reviewed_at=now; auto-advances",
"next": "accept-all-gating" }
]
label(required, playback) — the choice's name in the bubble.anchor.selector(required, playback) — the real button for that choice. An option without one is unusable; a decision with no usable options is a dead end (checker error).status_effect(optional, playback) — what really happens, in prose. The bubble shows just the first clause, arrow prettified: "IN_REVIEW → ACCEPTED". Effects starting with "none" show nothing.next(optional, playback) — where that branch goes. Leave it off and the branch falls through to whatever step happens to follow — usually wrong, so the checker warns.
pii_risk: flagging sensitive data
"pii_risk": {
"renders_pii": true,
"types": ["name"],
"mask_target": "The Submitted By and Reviewed By columns",
"mask_selectors": [
{ "selector": ".fi-table-cell-submitter\\.name", "mode": "blur" }
]
}
renders_pii,types,mask_target,note(notes) — the human record of what sensitive data the step shows and what should be hidden.mask_selectors(playback) — the machine-actionable part: regions Privacy Mode blurs ("mode": "blur", for text) or covers opaquely ("block", for document scans and images). An unknown mode degrades to blur — a typo must never mean "shown in the clear" — and when two guides mask the same region, block beats blur. Masks from every step of every guide covering the page apply for the whole browser-tab session while the toggle is on, so a recording has no gaps between steps. Flaggingrenders_piiwithout anymask_selectorsearns a checker warning: Privacy Mode can't act on prose.
Honest scope, verbatim in spirit from privacy-mode.md: Privacy Mode is a demo and screen-recording aid, not a security control. The data stays in the page; masking is purely visual.
Rendered from splain@a738cf8. The documentation is rendered from the package repository —
the same files that ship with Splain — so the site can't drift from the code.