Style guide
Style guide
The house style for every video made with the kit. The scripts, the classic template and
every kit section already implement it; this page exists so a fresh session knows why and doesn’t undo it.
A project can tighten these rules in its own docs (e.g. its payment test cards or seeded
accounts), but should not loosen them without a reason.
Standing instructions
- Structure: intro → framed recording with numbered callouts and zooms → recap (optional)
→ outro. Intro, recap and outro are sections picked per project or per video; the classic
set is the poster cover, a recap with the title left and ONE column of numbered steps right,
and a wordmark card (letters growing in from farther out, line wiping from the left). The
video ends on the brand, never on an empty frame: the last frame is what players show
when playback stops.
wordmarkandcompactsettle at 15 % opacity;endcardstays fully readable. No URLs on any card. - Intro = poster: frame 0 shows the brand and the title legibly — chat apps, GitHub
and Finder use it as the thumbnail — never hidden or blurred. The classic
poster: wordmark, accent line, tagline, then title, subtitle and the “N steps · S seconds” chip, centred; at t=0 everything is big (130 %) and 60 % transparent. It “breathes” down into place (sine.inOut, letters one after another from the centre), holds ~1.8 s, and at 3.0 s lifts out through the top while the recording rides in from below on the same upward motion. Other intros keep the poster rule and hand over withstage.enter(). - Background: deep navy with an aurora — three wide, blurred ribbons (brand colour, teal, blue/violet) that sweep, tilt and breathe on 4.5–6.5 s loops — plus a faint dot grid lit by a slow, wandering spotlight. Always moving, never busy.
- Cursor: large black macOS-style arrow with a white outline, press squash, and a brand-coloured double ring on click. It never jumps on click, and it keeps its size during zooms (the video draws it as a layer from the recorder’s log).
- Motion: gently curved, eased mouse paths with a little tremor, landing slightly off-centre; typing with uneven per-key delays. Human, never slow. The recording opens with the cursor resting in the middle third of the screen, never jumping in from a corner.
- People: realistic names and emails in the UI’s language from
demo.persona(). Nevertest@,e2e-123or seeded-looking data typed on camera. - Payments / external services: use the provider’s test mode and test data, the page in
the UI’s language, and remove waits (3-D Secure, redirects) with
demo.cut(). - Audio: one music bed per project (
music.file) under every video at −28 LUFS with fades; −34 LUFS under narration. Don’t pick a different track per video. - Quality: capture at 2x (2880x1800 for 1440x900, CRF 15) so text stays sharp in the
1080p frame and under zooms; render with
--video-frame-format jpg -q delivery; keepmedia.autoProxy: falseand nowill-changeon#frame. - Language: the product UI’s language. Callouts are imperative steps (≤ 6 words) because they also become the recap. At most 10 steps per video.
- Nothing pre-filled on camera: the recorder sends
X-Demo-Recording: 1; the app should skip local-only prefills when it sees it. Checkboxes and consents are ticked on camera. - Verify with frames, not logs: frame 0 (poster), settled intro, the hand-over to the recording, a callout, a zoom, the recap, the last frame of a real render.
- Zooms ride along with the cursor and finish before the click: zoom in while the cursor
glides to the first click it frames (starting ≤ 0.35 s before the glide), fully in 0.1 s
before that click; zoom out while it glides to the next target, done before that click.
A zoom holds its own clicks (plus a click in view right after them, < 1 s), not everything
still on screen; when the next glide is > 3 s away it lingers 1.2 s and leaves. No easing during a click. No extra pauses in the scenario to make room
for zooms — shorten the ease (≥ 0.4 s) instead. A zoom anchored with
clicksin video.json is timed this way automatically;reelson checkenforces it for every zoom.
What makes a video good
Plan the steps before the pixels. One feature, 3–6 visible state changes, each a sentence a reader could follow without the video. If you can’t write the callouts, the video isn’t ready.
Recording
demo.marker()right after the UI reaches the state, not before the click. The marker ends its step: the callout starts as the step begins (the first glide or click after the previous marker), stays through it and a moment on its result, so the viewer reads “Type the name” while the name is typed.- Pause 1–1.5 s after anything the viewer must read (a modal, a notification, results). The default settle after a click (0.7 s) is enough for menus only.
demo.click/demo.type/demo.moveTo, never rawpage.click/fill().- Small
demo.scroll()steps over big jumps; or navigate straight to the section. - Read values from the page instead of hard-coding seeded data.
- Wrap slow waits in
demo.cut(); a short glimpse is kept so the cut doesn’t feel like a glitch. - Trim the login off (video.json
"trim": { "start": "auto" }, the default) unless the video is about logging in. Tie trims to markers or clicks, never to plain seconds: a re-record moves the footage. - Keep the 1440x900 viewport; wider makes text tiny inside the 1920x1080 frame.
Composition
- Callouts: imperative, in the UI’s language, ≤ 6 words (“Click Save”, not “Now the user clicks the save button”). One on screen at a time, ≥ 2 s each.
- Zoom only where the UI is small (a field, a toggle, a badge), scale 1.5–2.0, timed by the cursor (rule 13).
- Title = the task (“Find a customer”), subtitle = the benefit or context. Keep the intro at
its section’s default (3 s for
poster); longer intros get skipped. - Recap rows must read as steps on their own (“Choose Card”), not commentary (“Here is the form”).
- Length: 15–30 s is the sweet spot. Over 45 s, split into two videos.
Verify the artifact, not the log
reelson check <slug>says “ready to render” (schemas, zoom timing,hyperframes check).- Extract and look at frames: t=0, settled intro, each callout, the zoom, the recap, the end.
ffprobeduration matches the timeline the build printed.- Watch it once at 1x. If a step is unreadable, pause longer in the scenario, not in the composition.
Iterating
| Want to change | Do |
|---|---|
| Callout text, zoom, trims | Edit video.json, reelson render <slug> (it rebuilds). |
| Pacing, missing step | Edit scenario.ts, reelson record, add the new marker to video.json, render. |
| UI changed | reelson record then reelson render: callouts follow their markers, zooms their click numbers (re-check if the clicks changed). |
| Look of intro/recap/outro | Pick another section (reelson templates), or make one (templates/README.md) — never per-video edits. |
Troubleshooting
- Scenario timed out on a locator: open
recording.failed.mp4, re-run with--headed. video_nested_in_timed_elementlint error: a timed<video>inside a timed wrapper.- A callout flashes at t=0 or a zoom starts zoomed in: an “out” tween without
immediateRender: false. - Blurry UI text (worst in zooms): capture must be 2x (
ffprobe recording.mp4→ 2880x1800),hyperframes.jsonautoProxy: false, nowill-changeon#frame. - Callout clipped during a zoom: it ended up inside
#frame; callouts live in#callouts. Cannot find module '@playwright/test'orreelson: command not found: runnpm install -g reelson && reelson install(or the kit checkout’sinstall.sh).… is invalid: … unknown key — did you mean …: a typo in reelson.config.json or video.json; the message names the path. Editors autocomplete both through their$schema.