Skip to document
erudeon/ops
unclassifiedskills/product-demo/references/engine.md

Engine internals

History(1)SourceLast changed 27 August 2026 by Bence

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 distanceclamp(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 to SCREENS, 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 DATA and a button to the toolbar's locale group. Every user-visible string must already live in DATA, or this turns into a second set of screens.
  • A new device frame: follow .browser / .phone — fixed pixel size, a .app mount inside, and fit() handles the rest. Add it to the FRAMES cycle.