Engine internals
Read this only when changing the engine itself. Building a demo needs SKILL.md alone.
The engine is roughly 400 lines inside assets/demo-template.html, between the marked ENGINE
banners. It has no dependencies and no build step.
Layout: the rig
.stage (fixed, inset 0)
└── .rig (absolute, transform-origin top left) ← one computed transform centres AND scales
├── .browser → .app#app-desktop ← 1240 x 800
├── .phone → .app#app-phone ← 400 x 856, 24px bezel
└── .cursor ← lives in rig space, not viewport space
fit() measures the rig unscaled, computes scale = min(fitWidth, fitHeight, 1) and applies
translate(x, y) scale(s) with an explicit centre.
It is done this way because CSS centring cannot be trusted here: an item wider than its container
gets safe centring, which pins it to the start edge and lets it overflow the far side. With both
frames the rig is 1694px, wider than many windows, so place-items: center silently pushed it 41px
off the right. The explicit transform has no such failure mode.
Both .app mounts declare container: app / inline-size. Screens must respond with
@container app (min-width: N), never media queries — two frames of different widths live in one
window, and a media query cannot tell them apart.
Motion
nextFrame(cb) races requestAnimationFrame against a 32ms timer, first to fire wins and the
other is ignored. In a painting window rAF answers in ~16ms and always wins, so motion is smooth. In
a suspended one the timer carries it.
A one-time capability probe was tried first and is not sufficient: a tween that had already
scheduled itself on rAF when frames stopped never resolves. Since crossfade holds the page at
opacity 0 across its midpoint, one stalled tween is a permanently blank demo. Racing every frame
individually cannot get stuck.
tween(dur, onUpdate, ease) returns a promise, drives onUpdate(easedProgress) per frame, and
is deliberately not abortable — abandoning it strands whatever it drives half-finished, and it is
also what a hand-off looks like: the user clicks, the scene stops, and their crossfade must still
play out.
Cancellation is a token. runId increments on stop; the scene calls guard() between steps,
which throws Abort, caught by the runner. Between steps is the only safe place to stop.
The pointer
point(sel) then press(sel, action), both awaited, wrapped as tapStep.
What makes it read as a hand rather than a script:
- Duration scales with distance —
clamp(240, 130 + dist × 0.78, 900)ms. Measured: 299px in 313ms, 968px in 817ms. A fixed-duration CSS transition is the loudest automation tell there is. - Ease-in-out, not ease-out. Measured coverage at the quarter/half/three-quarter marks is 4% / 41% / 88% against a linear 25/50/75.
- The path bows perpendicular to travel, up to 48px, peaking mid-flight. Arms pivot; they do not draw straight lines.
- A dwell on arrival (~190ms) before pressing. Nobody clicks the instant they land.
- First appearance fades in on the spot rather than flying in from a corner.
Because point() resolves on arrival, a press cannot fire mid-flight. An earlier version pressed on
a fixed schedule and had to teleport the cursor onto the target to compensate, which read far worse
than the problem it fixed.
The phone gets tapRipple() on its own copy of the control, since it has no pointer to show.
Rendering
paint() writes both mounts and rebinds. crossfade(nodes, mutate, out, in) fades out, runs
mutate at the invisible midpoint, gives the new DOM one real frame at zero so meters animate up
rather than appearing full, then fades in.
That frame gap is why fillMeters() is separate from paint(). Bars render at width: 0 with their
target in data-fill; the fill happens a frame later so the CSS transition has something to animate
from.
swapTo(screen) crossfades everything. nextQuestion() crossfades only .session-scroll, so the
header and progress bar stay put and the bar can travel to its new value instead of blinking.
Chapters
CHAPTERS[] of { id, title, enter(), async play() }. The runner loops
gotoChapter(i) → play() → advance. gotoChapter resets shared state, calls enter(), paints —
all inside a crossfade.
step(±1) stops autoplay, then jumps with animate = false. Scrubbing and autoplay fighting over
the screen helps nobody.
Toolbar
Fixed, bottom centre, z-index: 500. data-hud="off" is the deliberate sticky hide for recording
(H). Separately, data-hud-idle fades it after 3 seconds of a still mouse while playing, and
any mouse movement clears it — so the bar cannot be permanently lost, while H still wins for a
clean take.
Extending
- A new screen: write
screenX(), add it toSCREENS, give it a chapter. - A new icon: add a lucide path to
P. Paths only, no<svg>wrapper. - A new locale: add a block to
DATAand a button to the toolbar's locale group. Every user-visible string must already live inDATA, or this turns into a second set of screens. - A new device frame: follow
.browser/.phone— fixed pixel size, a.appmount inside, andfit()handles the rest. Add it to theFRAMEScycle.