Walkthrough recorder — recording and replay
How a real Profile Builder session becomes a replayable walkthrough: the two halves recording and replaying, the step model, the .rec.json format, the /records
How a real Profile Builder session becomes a replayable walkthrough: the two halves (recording and replaying), the step model, the .rec.json format, the /records admin page, the ::: steps embed and the player that mounts it. This is the reference for public/js/recorder.js, public/js/steps-player.js, public/js/records.js, lib/records.js, lib/step-diff.js and public/records.html.
What it is
The recorder is an admin-only authoring tool. An admin presses Record in the Profile Builder, works through a procedure, presses Stop, and gets a list of steps. Those steps can be saved to the hub, exported as a .rec.json, embedded in a guide with ::: steps, or played back live in the editor by anybody, signed in or not.
The two halves have deliberately split permissions, and the split is the design:
| Half | Gate | Who |
|---|---|---|
| Recording | <body data-can-record="1"> |
admins only |
| Replaying | ?record=<url> in the query string |
anybody, including signed out |
routes/seo.js injects the body attribute on the /editor route, and only when isAdminSession(req) is true:
html = setHtmlAttr(html, 'body', 'data-can-record', isAdminSession(req) ? '1' : '0');
setHtmlAttr() matches the opening tag with a regex and rewrites its attribute list rather than doing a literal html.replace('<body>', …). That is not fussiness: the editor's body is <body data-app-shell>, so the literal <body> is nowhere in the file, the replace quietly did nothing, and the recorder — which gates on document.body.dataset.canRecord — exited before it could show its button. To an admin that reads as "the feature is gone", with nothing anywhere reporting a failure. The helper throws when the tag is not found, so that particular silence cannot come back.
The client gate is one line:
var canRecord = document.body ? document.body.dataset.canRecord : '';
var wantsReplay = new URLSearchParams(location.search).has('record');
if (canRecord !== '1' && !wantsReplay) return;
The recording half — the edit hooks, #btn-record, the panel — stays behind if (canRecord === '1') inside boot(). The replay half does not. The reasoning is in the source: recording authors content through an admin API, while replay reads a published recording, which is public guide content that the signed-out reader of the guide has already downloaded.
See also Roles and permissions.
The step model
Every step goes through one function, addStep(), so the model cannot drift by kind. test/recorder.test.js asserts that no step is built by hand anywhere (steps.push({…}) does not appear in the file).
There are two kinds.
kind |
What it is | Source |
|---|---|---|
change |
one per committed document mutation | onBeforeEdit snapshots the doc, onEditCommit diffs it |
ui |
a click, change or typing that did NOT mutate the document | capture-phase click / change listeners |
onBeforeEdit stores window.EDHooks.getDoc() and sets pending. onEditCommit compares the snapshots with JSON.stringify; identical documents produce nothing at all. A real mutation then calls diff.describeSteps(before, after) and is titled with diff.stepTitle(changes).
The ui half covers what a reader is watching for and a diff cannot see: a tab switch, a mouse-tile pick, Undo/Redo, a dropdown, a form control.
Dedup rules
Three of them, and they exist because one click can otherwise leave three steps behind.
- A click is parked.
markInteraction()holds it forUI_STEP_DELAY + 60 = 410 ms(UI_STEP_DELAY = 350). A newer click supersedes an older still-pending one, so a rapid sequence leaves one step. - A mutation cancels it and takes over its target. If a document mutation lands inside that window,
clearPendingInteraction()drops the parked click and thechangestep inherits the target of the interaction that produced it. - A construct absorbs its own steps.
absorbConstructingUiSteps()pops a contiguous run of trailinguisteps withinCONSTRUCT_WINDOW = 2500 msand renumbers. "Add action" used to leave three steps: the click, the dropdown choice that filled it in, and the binding that came out. Only the binding is interesting.
Form controls
A click on a form control only arms it: armControl(el) remembers the value for 1500 ms. Only a change records a step, worded:
Set <field> to "<value>" (when the control has a readable value)
Change <field> (otherwise)
Recording the click itself was worse than redundant: elementLabel() is read at event time, so the click step was labelled with the value from before the edit.
Checkboxes and radios are deliberately not treated as form controls by isFormControl() — they toggle on click, so clicking one must keep recording the click.
Document resets
onDocReset records a step:
Load "<title>" (when the meta or the document has a name)
Load a profile (otherwise)
Without it a replay adopts documents that arrive from nowhere and a reader cannot tell that the recording changed subject half way through. onDocReset is also the signal that starts a pending replay, and the editor calls it from three places (public/js/editor.js: after adopt, after a load, and in loadNew).
Screenshots
There are none. They were removed in v2. test/recorder.test.js pins the absence of renderRegion, cropToRegion, drawHighlight, drawCursor, loadCapture, enqueueCapture, captureStep, grabFrame, noteCaptureFailure, frameCodec, captureChain and captureFailures, and of the modern-screenshot / html2canvas dependencies. A step is an action — what you did, to which control, and the document it left behind.
The admin list still renders a thumbnail field if a v1 recording has one, and shows "No screenshots" for a recording whose first step has no step.image.
What a step contains
The full v2 shape:
{
"id": 3,
"kind": "ui",
"at": 41255,
"title": "Set Kind to \"Mouse button\"",
"details": "",
"diff": ["Mouse button", "kind"],
"spcf": "<the document after this step>",
"spcfBefore": "<the document before this step>",
"focus": {
"view": "diagram",
"group": 0,
"button": "right",
"mapping": 1,
"target": {
"view": "diagram",
"pane": true,
"label": "Pick \"Mouse button\" for Kind",
"id": "cd-kind"
},
"value": "Mouse button"
}
}
at is milliseconds since the recording started (Date.now() - startedAt). In older files it is a raw epoch value, because startedAt was not set when they were made; nothing reads it for anything except a human.
spcfBefore is not redundant with spcf. A step that replaces the control it used — creating a binding turns "Create the binding" into the binding editor — can only be demonstrated on the screen it was used on. Both states are kept, and beforeSpcf is advanced to step.spcf after every step so each one records its own "before".
A target is an identity, not a picture
A rect goes stale the moment the window is a different size, and a pixel offset is meaningless next to a panel that has re-laid-out. So a step carries an identity:
{
"view": "diagram",
"pane": true,
"label": "Select Right Button on the mouse",
"id": "…",
"data": { "data-btn": "right" },
"name": "…",
"approx": "button",
"text": "first 40 characters of the first line"
}
IDENTIFYING_DATA (public/js/recorder.js:371) is the ordered list of attributes that can take part in singling a control out, most specific first:
['data-btn',
'data-idx', 'data-depth', 'data-parent', 'data-nest-stage',
'data-nest-add', 'data-nest-del', 'data-nest-menu', 'data-nest-confirm',
'data-nest-kind', 'data-nest-ctl', 'data-nest-parent', 'data-nest-back',
'data-nest-yes', 'data-nest-no',
'data-view', 'data-act', 'data-kind']
data-chain-idx is gone, and the three that replaced it are a set rather than three independent identifiers. The Bindings tab is a nesting tree now, keyed by raw index, so a row is named by {data-idx, data-depth, data-parent} together and by nothing about where it sits in the tree. data-idx on its own is not an identity: the press-stage rows, the force-axis rungs in the binding editor and the output actions in the composer all carry it too, and the first of them in the document is not necessarily the one the step was about. data-depth is what makes a row a row.
A press-stage row — a control's own light press and its deep zones, drawn in the card instead of in a tree — is named the same way, by its index together with data-nest-stage. It cannot be told from a tree row by depth and parent, because it is in no tree at all: without that second attribute a press stage would collide with a tree row on the same data-idx, and with the force-axis rungs in the binding editor, which draw the same bindings again.
The data-nest-* half tells the Add-menu and the inline question about a mapping's followers apart. Every control in a menu carries the raw index of the row it belongs to, so data-nest-parent is what stops a trigger-type option from matching every other menu's copy of the same type. data-nest-stages and data-nest-card are deliberately not listed: both are blocks rather than controls, and both repeat once per layer, so neither can single an element out.
describeTarget() fills id when there is one, then data from whichever identifying attributes are present, then name only when there is neither. approx + text is the last resort and is deliberately lossy.
resolveTarget() looks the identity up in the live editor, in this order:
document.getElementById(t.id)- one compound selector built out of every recorded attribute —
dataSelector(t.data)concatenates all of them — because in the nesting tree only the combination matches a single element. When nothing matches whole it falls back to one key at a time, in key order: a recording made before the nesting tree carries a single key, and it reads exactly as it always did. document.querySelector('[name="…"]')- every
t.approxelement, matchingt.textagainst the first 40 characters of its first line
When it finds nothing it returns null, and the caller retries. showStep()'s attempt() runs up to 8 times at 80 ms, because the editor re-renders asynchronously and a target that was not there a moment ago can be. applyRecordedValue() retries 6 times for the same reason.
The recording panel
#ed-rec is a fixed, collapsible panel. It collapses (ed-rec--min) to a slim edge bar so it never covers the editor.
The header carries a red dot, the word "Recording", a live N steps count, and a collapse chevron (▾ / ▸, aria-expanded, title "Collapse panel" / "Expand panel").
Each row is:
- the step number
- the title
- the diff chips
- the control the step used, and the value it set, when there is one
- up to 4 detail lines
- an
×button (title "Remove this step") that splices the step out
The empty state reads:
Changes and clicks (tabs, buttons, mouse picks) record as steps. Hit ■ Stop to export.
#btn-record toggles. It is an icon in a box, not a word in an icon button: the SVG is a circle when idle and a rounded square when recording. It carries aria-pressed, and its title flips between
Record this session as a guideStop recording and export
The button ships hidden in public/editor.html and is revealed by recorder.js (btn.hidden = false) only for admins.
Starting toasts Recording started — every change and click becomes a step.; stopping toasts Recording stopped — N step(s)., stores finalSpcf = buildText() and auto-saves to the hub when at least one step was recorded.
The export dialog
#rec-export-backdrop is a modal with #rec-name (maxlength="60"), a line reading N steps · ~N KB, and six actions.
| Action | data-act |
What it does |
|---|---|---|
| Download guide draft (.md) | guide |
front matter + steps + the embed line |
| Download recording (.rec.json) | rec |
slugId(name) + '.rec.json' |
| Save to hub | save |
POST api/records |
| Open /records | open |
href="records", target="_blank" |
| Copy for Discord | discord |
clipboard |
| Close | close |
removes the backdrop |
The guide draft is a complete page, not a fragment:
---
title: <profile title>
description: <profile description, or "A step-by-step recording from the Profile Builder.">
tags:
- guide
- recorded
order: 95
---
# <profile title>
> [!tip] About this recording
> This walkthrough was recorded in the Profile Builder. Follow the steps below (each shows where to click and what changes) — or scrub through the interactive steps embed.
## What you will do
1. **Select Right Button on the mouse**
2. **Add a Right Button binding that key W, shift** — `Right Button` · `binding`
## Watch and follow along
::: steps "<profile title>" recordings/<slug>.rec.json
:::
## Related guides
- [[interface|The interface, your way around]]
- [[bindings|Every trigger & action]]
The Discord copy is the same numbered list without the front matter or the embed:
**<title>**
1. **<step title>** — chip · chip
2. **<step title>**
The .rec.json format
The v2 document the recorder writes:
{
"version": 2,
"id": "<slug>",
"title": "<profile title>",
"description": "<profile description>",
"tags": ["guide", "recorded"],
"created": "<ISO timestamp>",
"startSpcf": "<document when recording started>",
"finalSpcf": "<document when recording stopped>",
"steps": [ "…" ]
}
Old recordings still work
Version 1 steps carried image / frame / crop and no target. Both readers still accept them:
- the steps player renders
step.imagewhen present, and invents nothing when it is not; locateStepTarget()infers a target from the step's title and its diff chips.
The shipped recording docs/recordings/testing-replays.rec.json is a real example: it is version: 1, has no image on any of its six steps, and already carries focus blocks.
The fallback in locateStepTarget() recognises four shapes:
| Step title matches | Pane | Target |
|---|---|---|
^Select <name> on the mouse$ |
close |
the mouse tile with that visible name |
added binding / created binding / new binding |
add |
#add-trig-btn |
a diff chip matching binding|action|output |
open |
#composer-activation, or #composer-deactivation if the title says so |
^(undo|redo)$ |
close |
nothing — the step is the event |
It is consulted even when a step has a recorded focus, because a recorded focus can name a control that the step's own action replaced, or one the editor rebuilt under a different id. Anything unrecognised returns null rather than inventing a target.
Slugs
There are three slug functions and they are not identical:
| Function | Where | Rules |
|---|---|---|
slugId(name) |
public/js/recorder.js |
lowercase, [^a-z0-9.] → -, collapse, trim, fallback recording. No length cap. |
slugify(name) |
lib/records.js (server) |
lowercase, [^a-z0-9._-] → -, collapse, trim, slice(0, 80), fallback recording |
slugPreview(slug, title) |
public/js/records.js (client) |
same rules as slugify, and builds the two things that quote it |
The server's version is the authority: validate() runs every incoming slug through slugify before it touches the database, and repoPath() runs it again before touching the filesystem.
A quirk worth knowing: buildRecDoc() in public/js/records.js writes version: 1 when the admin page re-saves an edited recording, while buildRecJson() in recorder.js writes version: 2. The players ignore version and read the fields, so this is cosmetic — but a file downloaded from /records after a title edit will claim to be v1.
Limits
lib/records.js:
| Rule | Value |
|---|---|
MAX_BYTES of data |
50 * 1024 * 1024 (50 MB) |
| title | sliced to 200 |
| description | sliced to 2000 |
data |
must be a string that parses as JSON |
| parsed document | must be an object with a steps array |
The server JSON body limit is 64 MB (express.json({ limit: '64mb' }) in server.js), which is generous because v1 screenshots are data URLs. Over that, the error handler answers 413 Body too large. Maximum size is 64 MB.
The recordings admin page
/records, admin-gated by requireAdminPage(req, res, '/records') in routes/seo.js. It is deliberately not in the nav: it is an authoring surface, not a destination.
public/records.html carries <body data-page="records"> and no data-app-shell. The comment above the tag explains why: the recordings library is an ordinary scrolling page that happens to be admin-only, and wearing the app-shell flag meant the body clipped its own content and the shared footer was suppressed.
Tool strip
| Element | Id | Behaviour |
|---|---|---|
| Import .rec.json… | #rec-import |
opens #rec-file (accept=".rec.json,application/json") |
| Refresh | #rec-refresh |
re-fetches the list |
| New in Profile Builder | ghost link | ./editor |
importFile() reads the file with FileReader, refuses it with That file is not valid JSON. or Not a .rec.json recording (missing steps array)., and otherwise POSTs it with slug: parsed.id || file.name.
Stats
#rec-stats renders three chips: N recordings, N steps (summed across all), N published.
The list
One article.rec-card per recording, with data-card="<slug>" and tabindex="0" — clicking the card, or pressing Enter or Space, opens it. The click handler ignores a click that landed on one of the card's own buttons.
Each card carries:
- a thumbnail from
r.thumb(which issteps[0].image), or the camera icon and "No screenshots" - the title and a
published/draftbadge - chips: step count, humanised size (
fmtBytes), slug, last-updated - the description, when there is one
- Open, Copy embed, Delete
Delete is guarded by confirm('Delete recording "<slug>"?'), and the confirm gains Its published repo file will be removed too. when the card carries the published badge.
The detail
#rec-detail, with a toolbar:
| Control | Id | Effect |
|---|---|---|
| ← All recordings | #rec-back |
back to the list |
| slug chip | — | the slug as it currently is |
| Publish to guides / Unpublish | #rec-publish / #rec-unpublish |
POST …/publish or …/unpublish |
| Download .rec.json | #rec-download |
buildRecDoc() as a blob |
| Save changes | #rec-save |
rename (if needed) then upsert |
The form:
| Field | Id | Limit |
|---|---|---|
| Title | #rec-title |
maxlength="200" |
| Slug | #rec-slug |
maxlength="80", spellcheck="false", hint "This names the file, the Edit-in-editor address and the embed snippet." |
| Description | #rec-description |
maxlength="2000", 3 rows |
| Edit-in-editor | #rec-link |
read-only <code>, /editor?record=docs/recordings/<slug>.rec.json |
Both the Edit-in-editor line and the embed snippet are rebuilt live on every keystroke in the slug field, from the single slugPreview() function, so a rename can be checked before it is saved.
Steps are an <ol> where every step title is an <input maxlength="200" aria-label="Step title"> with its diff chips (as <code>), its details, and an × that splices the step out.
Save is two requests. If the slug changed, PUT api/records/<old slug> runs first and the content is written after it, so the data lands under the new name. A rename of a published recording surfaces the server's warning as an error-styled toast:
Any guide embedding the old slug needs its snippet updated.
That string comes from lib/records.js rename(), which also moves the published file with the row via fs.renameSync.
A quirk: publishRec() checks out.snippet, but the server returns embed. That branch is never taken, so publishing always toasts Published rather than Published — embed this in a guide.
The records API
Seven routes in routes/records.js, all behind requireAdmin.
| Method | Path | Returns |
|---|---|---|
GET |
/api/records |
{ records: [{ id, slug, title, description, published, stepCount, bytes, thumb, createdAt, updatedAt }] }, newest-updated first |
GET |
/api/records/:slug |
the full parsed record plus steps, startSpcf, finalSpcf, sizeBytes, published, ownerUserId; 404 Recording not found. |
POST |
/api/records |
upsert { slug, title, description, data }; 201 created / 200 updated, body { ok, id, slug, created }; 400 on validation |
PUT |
/api/records/:slug |
rename only — body { slug }. Returns { slug, renamed, wasPublished, warning } |
POST |
/api/records/:slug/publish |
{ ok, file, embed, dataSrc }; writes the repo file and sets published = 1 |
POST |
/api/records/:slug/unpublish |
{ ok: true }; removes the repo file, published = 0 |
DELETE |
/api/records/:slug |
{ ok, removed }; removes the row and the repo file |
The rename is a separate request on purpose: a recording can be tens of megabytes, and resending all of it to change a name is slow and a chance to lose data to a failed upload.
The publish response is the exact thing to paste into a guide:
{
"ok": true,
"file": "docs/recordings/<slug>.rec.json",
"embed": "::: steps \"<title>\" recordings/<slug>.rec.json",
"dataSrc": "/docs/recordings/<slug>.rec.json"
}
Guide embeds hit a public read path in routes/docs.js — the repo file first, the recordings table second — never this router.
Replay in the Builder
/editor?record=<url>. The bar #ed-replay carries:
Replaying **<title>**i / N — <step title>(orEnd — Finished profilepast the last step)◀ Back,Next ▶, and×(title "Exit replay")- a clickable step list
The start index comes from ?step=N; anything out of range falls back to 0. Next is never disabled, and on the last step it is relabelled Result →.
One step, one screen
Each step adopts spcfBefore, falling back to startSpcf for step 0 and finalSpcf later:
var spcf = step.spcfBefore || (i === 0 ? replay.rec.startSpcf : replay.rec.finalSpcf);
This is the screen before the step, with the control ringed, because the step's own result is what you see when you move on. Creating a binding turns the "Create the binding" button into the binding editor; a replay that showed the result could never point at the button.
Pressing Next past the last step adopts finalSpcf once, deliberately, and toasts End of recording. Nothing moves underneath a step while it is being read — there is no timer in goto().
The mechanics
setReplayState(focus)restores the view, the layer, the button and the binding in one render, then forces the pane on.applyRecordedValue(focus)fills the control. For a<select>it matches the option by its visible label, assigns that option'svalue, and fires bothinputandchange. For everything else it assigns the value and fires onlyinput— re-firingchangewould write the same value a second time and add a spurious edit.- The ring is
.ed-replay-box,position: fixed, drawn afterscrollIntoView({ behavior: 'auto' })and re-measured on any capture-phasescrollorresize. Measuring during a smooth scroll left the ring sitting above and left of its target. - A binding step rings two things: the composer and the
#cd-addinside it. clearReplayMark()removes the rings and the.ed-replay-targetclass;closeReplayBar()does not, because the bar is rebuilt on every step.
Failures toast Could not load the recording., log to console.error, and a success logs console.info. The boot() guard (replayStarted / pendingReplayUrl) makes it start exactly once; onDocReset also calls startPendingReplay().
window.EDHooks
The replay API the recorder drives, defined at the end of public/js/editor.js:
window.EDHooks = {
getDoc, getBuildText, getMeta, adopt,
setView, getView, selectMapping, getMappingCount,
showPane('close' | 'add' | 'open'),
getReplayState, setReplayState, clearSelection, getSelection
};
showPane has three states because "open" is ambiguous: a step may be about the editor for a binding that exists (open, which keeps the selection), or about the form that creates one (add, which clears the selection), or about nothing at all (close, which also clears the selection). The pane is part of the layout and is never hidden.
The steps player (the embed)
public/js/steps-player.js exposes window.SPSteps.init(). It is self-contained on purpose: it fetches the .rec.json behind the box it is given and knows nothing about guides, the explorer, or the page it used to live in — which is how the same block works in a guide and in a forum post.
It mounts every .d-steps box and every .fr-steps box; the forum's markdown emits fr-steps and the player ADDS d-steps to it, so one query finds both. The d-steps* class names are kept because they are a component and css/docs.css already styles them.
For each box it fetches box[data-src] and renders:
- a header with the title from
data-title(default "Walkthrough") andi / N - the step: number, an optional screenshot (
step.image, v1 only — nothing is invented for v2), title, diff chips, details - a target line
view · label · value, read fromstep.focus.targetwith fallbacks to the top-levelstep.targetandstep.value - navigation:
← Prev, a dot per step (clickable, withtitleandaria-labelper step),Next →
Then it appends these links:
Open live in Profile Builder -> {BASE}/editor?record=<src>
Plays in the real editor. Nothing is saved to your account.
Manage this recording -> {BASE}/records (admin only)
An unreachable /api/auth/me still mounts every box as non-admin — a published recording is public content, so leaving the page full of loading placeholders would be the wrong failure.
The error state is is-error plus This walkthrough could not be loaded right now.
A harmless defect: steps-player.js declares BASE (and the firstSeg computation) twice — once inside a block comment and once in code. The second wins, both are identical, and nothing is affected.
Step titles
lib/step-diff.js, loaded in the browser as window.SPHubStepDiff and in Node as module.exports. test/step-titles.test.js covers it.
describeSteps(before, after):
- normalises both raw
.spcfdocuments throughsummarizeDoc()and the parser'ssummarizeMapping(), so the two sides are compared like with like; - diffs the profile name (
Renamed profile "A" → "B"); - per group: a rename, then one changed, added or removed binding —
diffMappingLists()returns after the first changed binding, so one binding diff per group per step — or an added/removed group; - slices to
CHANGES_PER_STEP = 2.
Chips are [before, after] pairs, rendered identically by the recorder, the player and the admin page.
stepTitle(changes) is readableTitle(changes[0]), which rewrites the machine title into a sentence:
| Machine title | Published title |
|---|---|
Added binding: Right Button | Press Button => key W, shift |
Add a Right Button binding that key W, shift |
Removed binding: Middle Button | … |
Remove the Middle Button binding |
Changed a binding: … => nothing |
Clear the binding’s actions |
Changed a binding: … => Right click |
Change the binding to Right click |
"Right Button": … → Right click |
Set Right Button to Right click |
Renamed group "…" → "…" |
unchanged |
Renamed profile "…" → "…" |
unchanged |
Created a profile |
Start a new profile |
| anything unrecognised | unchanged, never dropped |
Group and profile renames pass through deliberately. actions() de-duplicates a list like Right click,Right click, because repeating an action is a fact about the file and not about what the author did. Created a profile is the only synthetic title; an empty title becomes Edit the profile.
See also SpCF format.
Embedding a recording
The recipe: record, export the draft if you want it, publish from /records, then copy the returned embed snippet into the guide.
In a guide:
::: steps "One binding: right button → right click" recordings/testing-replays.rec.json
Every recording on the site lives in one directory, docs/recordings/, and a guide's embed resolves there whatever folder the guide is in. The path is not relative to the embedding guide, and that is the whole point: a recording is a shared asset rather than something a guide's folder owns. Two guides may show the same walkthrough, and it must not live in either of them — so recordings/<slug>.rec.json written from profile-builder/testing.md, from docs/testing.md and from a guide three folders down all produce the same /docs/recordings/<slug>.rec.json and the same player.
What the resolver (resolveRecordingRel() in lib/docs/index.js) accepts:
| Written | Becomes |
|---|---|
recordings/x.rec.json |
recordings/x.rec.json — the form every guide should write |
profile-builder/recordings/x.rec.json |
recordings/x.rec.json — the old source form still resolves, so a leftover in a guide renders instead of warning |
some/deeper/recordings/x.rec.json |
recordings/x.rec.json — the prefix is discarded |
/docs/recordings/x.rec.json, …?v=2#step-3 |
recordings/x.rec.json — an address is accepted too |
../recordings/x.rec.json |
refused — a climbing path is refused, never normalised away |
notes/x.md, https://x/y.rec.json |
refused — not a recording, and not ours |
x.rec.json |
refused — the recordings/ segment is part of the address |
Then it looks for a repo file under docs/, and falls back to the recordings table. If neither exists the reader gets a warning callout naming the address it wanted:
Missing steps — No recording at
recordings/x.rec.json. Paint it with the ● Record button in the Profile Builder, or upload it on the recordings page.
The old URL /docs/profile-builder/recordings/<slug>.rec.json is gone and nothing redirects to it. The old source path still resolves, because the file is real and the author simply named it the old way; the fix for a guide that prints the warning is to write recordings/<slug>.rec.json.
In a forum post:
::: steps "One binding: right button → right click" recordings/testing-replays.rec.json
The same syntax, but the forum resolver (setRecordingResolver() in lib/forum.js) reduces the path to a basename with records.slugFromPath() before the table is asked — a post is written by anybody, and this is the one place a post reaches the filesystem — and requires row.published. An unpublished recording in a post always renders No walkthrough at ….
The shipped example is docs/profile-builder/testing.md, which embeds
::: steps "One binding: right button → right click" recordings/testing-replays.rec.json. docs/testing.md is the second one, and the reason the rule is a rule: it lives at the root of docs/, and before the recordings directory was global it pointed at docs/recordings/… through a join with its own folder — which happened to be the right answer, and would not have been from about/.
See also Markdown blocks and Guides system.
How this is tested
test/recorder.test.js (931 lines) is largely a regression file: the tests are named after the faults they pin. The themes:
- the record-permission body flag survives a body tag that has attributes; setting it twice does not duplicate it; a missing tag throws; and the editor route uses the helper rather than a literal
'<body>'replace; - recording is gated on the server flag and replay is not, so a signed-out reader following a guide does not get an empty editor;
- the record button is an icon in a box; the small glyph buttons centre their glyph; the recordings library scrolls like a document rather than wearing
data-app-shell; - nothing in a recording is a screenshot any more — the absence list, the dependencies and the vendored renderer;
- a step names the control it acted on; a target is an identity, not a position; a step records the screen, the value and the document on both sides;
- a step records the screen as it was before it; one step, one screen, and nothing moves underneath it; the ring is the attention colour (
--orange), not the accent; - the ring is placed after the scroll, follows the element on capture-phase scroll, and is not cleared when the bar is rebuilt;
- the title is read even when a step has a recorded focus, so a focus that resolves to nothing is not a step with no ring;
- the slug is editable and the three places that quote it follow it; a rename is its own request and the server moves the published file with the row;
- a signed-out reader can replay a published recording, and every
GETinroutes/records.jsis admin-only; - the replay waits for the editor rather than a delay, and a replay that cannot load says why.
test/records.test.js drives lib/records.js directly: save upserts and slugifies sensibly; a payload that is not a .rec.json document is refused with a message mentioning steps, JSON or data; list and getWithStats expose the stored steps; publish writes the repo file while unpublish and remove clean it up; and slugFromPath derives the slug across recordings/…, /docs/…?x=1 and a non-recording path. test/docs-recordings-root.test.js is the guide-side half: it builds a fixture tree with the recording in recordings/ and guides at the root, in profile-builder/ and three folders down, and asserts all three resolve to the same URL, that the old profile-builder/recordings/… source form still resolves, and that a climbing or non-recording path is still refused.
Also relevant: test/step-diff.test.js, test/step-titles.test.js and test/docs-steps.test.js.
See also Testing.