Product demo
name: product-demo description: Build a hardcoded, self-contained HTML product demo of Pass The Year or Flow — device-framed, autoplaying, scrubbable — for marketing videos, landing page hero loops, sales decks, onboarding explainers, investor material, App Store previews and feature announcements. Use this whenever someone wants to SHOW a product flow that is not built yet or not worth wiring to a real environment: "make a demo", "a mockup of the study flow", "something to record for the landing page", "a preview of the new onboarding", "show what checkout will look like". Also use it when asked to update, re-brand, translate or re-cut an existing demo. Reach for it even when the words "demo" or "mockup" are absent but the ask is plainly a scripted walkthrough of product screens.
You are building a fake product that looks exactly like the real one. Every pixel is hardcoded,
nothing talks to a server, and the whole thing is one .html file somebody can email, open from
their Downloads folder with no network, and screen-record.
The value is entirely in the resemblance. A demo that looks almost like the product is worse than no demo, because it teaches viewers a UI that does not exist and quietly makes the real thing feel like a downgrade. Everything below serves that.
The one rule that matters
Read the real components before you write a screen. Do not design from imagination.
This is not a style preference. On the build this skill came from, every screen was written twice because of guessing:
| Guessed | Actually |
|---|---|
| A left sidebar nav | A 56px top bar with inline nav; there is no sidebar anywhere |
| Mastery labels "Strong / Getting there / Shaky" | Just started / Learning / Proficient / Mastered |
| A hand-drawn "P" logo chip | An uploaded SVG lockup, not in the repo at all |
| Course covers as icon tiles | A deterministic hash-to-gradient with the subject's initials |
| Coloured eyebrow labels above headings | Founder directive: never use eyebrows |
Each of those looked plausible and was wrong, and wrong is expensive: the demo goes into marketing material, where a stranger's first impression of the product gets formed by it.
So before writing anything, find and read:
apps/web/app/styles/tokens.css— the palette. Copy OKLCH values verbatim; do not eyeball a green.- The page file for each screen (
apps/web/app/(lms)/**/page.tsx,app/flow/**) — layout, container widths, what sections exist and in what order. - The components it renders — the real
Card,Meter,Stat,PageHead,SectionHeadcarry exact sizes, weights and letter-spacing. Copy them. - Any
*-labels.ts/ copy constant — the product's own vocabulary. Inventing a synonym for a state the product already names is the single most common tell. apps/web/lib/lms/nav.tsor the equivalent — which destinations exist, and which are behind a launch flag and therefore must NOT appear.
When you cannot find something, say so and ask rather than inventing a plausible version.
Making one
-
Copy the template.
assets/demo-template.htmlis a complete, working Pass The Year demo. Start from it; never start from a blank file. Copy to wherever the demo should live — these are deliverables, not source, so a Downloads folder or amarketing/directory is fine. -
Research the flow as above. Take notes on the exact strings and numbers you will need.
-
Re-brand if it is not PTY.
scripts/embed-brand.py <file> --host onlyonflow.compulls the live logo and inlines it. Swap the:root/[data-theme="dark"]token blocks for the product's own values fromtokens.css. -
Write
DATA— every string a viewer reads, one block per locale. Nothing user-visible may be hardcoded inside a screen function, or a second locale becomes a second set of screens. -
Write the screens, replacing the
screen*()functions and theSCREENSmap. -
Write
CHAPTERS— see below. -
Verify — see below. Then hand over the file path and tell them H hides the toolbar before recording.
The engine, and what you must not rebuild
The template's marked ENGINE regions give you device frames that scale to any window, a pointer that moves like a hand, crossfaded screen changes, a seekable chapter timeline, a recording toolbar, and light/dark plus locale switching. Every one of those has a subtle bug already fixed in it. Rebuilding any of them by hand reintroduces the bugs, so change them only with a specific reason.
You write: the brand tokens, the CSS for your screens, DATA, the screen*() functions, and
CHAPTERS.
The engine hands your screens these helpers — use them rather than writing new ones, so every demo
looks like it came from the same place: meter(label, value, tone), sectionHead(title, action),
cover(motif, key, label), adaptiveMark(), topbar(active), tabbar(active), ico(name).
Screens are functions returning an HTML string, rendered into a desktop frame and a phone frame
simultaneously. Layout responds via container queries on .app, not media queries — the frames
are two different widths inside one window, so a media query would apply the same breakpoint to both.
Anything the pointer clicks or the script mutates needs a data-el="name", because it exists twice
and both copies must stay in step.
references/engine.md documents the internals. Read it only if you need to change the engine.
Chapters
A demo is a list of chapters, and each is deliberately split in two:
{ id: "results", title: "Results",
enter() { S.screen = "summary"; S.results = ["correct", "incorrect", "correct"]; },
async play() { await hold(4200); } }
enter() sets state synchronously. That is what makes a chapter seekable: jumping to it never
replays anything before it, so the arrow keys land instantly. play() runs the pointer choreography
once you are there.
Keep that split honest. If enter() depends on a previous chapter having run, scrubbing breaks —
and scrubbing is what makes the file useful for cutting video, because an editor can park on the
exact beat they need instead of waiting out a loop.
Build play() from tapStep(selector, action), which reaches for a control, presses it and runs the
action. Do not click a "next" button at a chapter boundary: the boundary IS that transition, and a
click there double-advances.
Pacing: a viewer needs roughly 3 seconds to take in a simple screen and 5 or more to actually read an explanation. Err slow — dead air is trivial to cut in an editor, whereas a beat that flashes past cannot be recovered.
House rules
- No eyebrows. Never a small coloured label above a heading. Put the context in a line beneath the heading, or in the heading itself. This is a standing founder directive.
- Use the real logo, fetched from the running app. There is no copy in the repo.
- Real vocabulary only — the product's own state names, button copy and empty-state wording.
- No em dashes in user-visible copy. House style, and it applies to demo copy too.
- Icons are SVG (lucide paths, already in the
Pmap). Emoji are fine as decoration, never as a control. - Seed plausibly. Real university, real programme, real course names, believable Dutch names, numbers that agree with each other. A demo showing "9 to fix" on one screen and 3 wrong answers on the next is a bug a viewer will notice.
- Never put anything in a demo that could be mistaken for a real person's data.
Traps
These all cost real debugging time. They are in the template already; they bite again the moment you extend it.
Frames may not run. Everything animated goes through nextFrame(), which races
requestAnimationFrame against a timer. rAF alone is not safe: in a page that is not being painted
it never fires, and because a crossfade holds the screen at opacity 0 across its midpoint, a stalled
tween means a blank demo with no way out. Never call requestAnimationFrame directly.
Tweens are not abortable. Abandoning one strands whatever it drives at a half-finished value.
The scene abandons itself at guard() calls between steps, which is the only safe place.
Grid centring is "safe" centring. An item wider than its container gets pinned to the start edge
and overflows the other side rather than centring. The rig is positioned by an explicit computed
transform for exactly this reason; do not replace it with place-items: center.
.app svg { width: 16px } is a defensive default. An inline SVG with no width stretches to fill
its box, so one unstyled icon can eat a whole row. Anything wanting a different size must
out-specify that rule — a bare class on the <svg> loses to it whatever the source order.
<i> is inline. width does not apply. Meter fills are display: block.
backdrop-filter recomposites its whole layer every frame. It is why the dock and tab bar use a
solid --card. Do not reintroduce it on anything visible during a transition.
Pointer motion must scale with distance. A fixed-duration CSS transition makes a 40px hop and a 900px sweep take the same time, which is the loudest "this is automated" tell. The engine derives duration from distance, eases in and out, and bows the path slightly.
Verify before you hand it over
Inspection is not enough — most of the above fails silently.
python3 -c "import pathlib,re;h=pathlib.Path('demo.html').read_text();open('/tmp/d.js','w').write(re.search(r'<script>\n(.*)\n</script>',h,re.S).group(1))" && node --check /tmp/d.js
Then open it and confirm, by looking:
- Every chapter renders, and
←/→step through all of them and wrap at both ends. - Both frames show the same state; the phone is not clipped and the rig is centred at several window sizes.
- Light and dark. A theme is one keypress (
D) and a broken dark mode is a wasted shoot. - Every locale (
L). Translated strings are usually longer; check nothing wraps badly. - The console is clean.
- One uninterrupted loop, watched end to end. This is the step most worth not skipping: measurement confirms the parts, only watching confirms the whole.
If you verified something by measuring rather than watching, say so when you hand it over. Whoever records it should know what was and was not seen.