UI demo composer
A product video here is one HyperFrames composition built from three files you write per video and a kit shared by every video:
| File | What it is | Who edits it |
|---|---|---|
video/build.py | The beat table T: every time derived from word timings | You |
video/src/template.tpl | The rebuilt UI, the screen layers and the GSAP timeline | You |
video/src/app.css | The product UI CSS, literal values from specs/ | You |
kit/stage.css, kit/titles.css, kit/stage.js | Stage layers and seek-safe helpers | This skill |
products/<slug>/kit/tokens.css, fonts/, icons/, ui/ | The product's tokens, fonts, icons and shared UI pieces | product-kit |
video/index.html | Generated by build.py. Never edit it by hand | Nobody |
scripts/buildlib.py does the generic part of every build (timings, the signature gate,
audio tags, inlining, placeholders, icons, the camera clamp) so build.py stays a short list of
beats.
The stage model
#root > #stage > #camera > #blurWrap > #app the product, in app pixels
> #veil, .pvs-ttl, #endS, #lock, #fade screen space, 1920x1080
- The UI is rebuilt at its real viewport and scaled once.
#appisproduct.yaml app_canvas.logicalCSS pixels (for example 1440x810), scaled to 1920x1080 by one transform. Production CSS keeps its literal pixels, so a spec value pastes in unchanged and every measurement compares 1:1 against the code. Pick a desktop width above the app's breakpoint, in 16:9; if there is a screen recording, use its logical size. A canvas that is not 16:9 is letterboxed (buildlib warns). - Positions are measured, never typed.
S.box(sel)sums offsets up to#appafter fonts load. If the CSS changes, the cursor still lands on the button. - The cursor and the click ripple are the last children of
#app, so they scale and blur with the product. Pop-ups (.pvs-modal) and toasts (.pvs-toast-slot) live in#apptoo. - Only
#cameramoves and only#blurWrapblurs. Never tween#appor#stage.
Framing: 1x full page by default
Default every product beat to 1x, full page, with pop-ups at their natural size and centred
the way the app shows them. Push-ins are gentle (up to video.max_zoom, 1.35 by default),
keep the whole section in frame and never put a frame edge through a label. One framing per
chapter: make the move behind the chapter title while the product is out of focus, or as one
slow signature move.
Why: in the production these skills come from, a settings video was rejected twice for zoom.
A 1.67x punch-in cut labels in half ("I can't tell what is happening"), and later framings
of about 2x per column felt cramped even with nothing cut. The video the reviewer held up as
the model ran at 1x the whole time. A far-out canvas (shown at 50 %) or a narrow chat column
tolerates more: raise max_zoom in product.yaml for that product, not per beat.
b.cam() and S.cam() clamp above max_zoom and warn. Details and numbers:
references/framing-and-pacing.md.
Pacing: chapters are pauses
- Open on context, never mid-UI: logo, product name and one line on what it is, over the product out of focus.
- Each chapter opens with a two-tone title over the product racked out of focus (blur plus a veil), holds at least about 1.4 s, and focus returns just before the word that names the first action. Screen changes happen behind the title, so there are no hard cuts.
- One idea per chapter, one change at a time, 0.6 to 1 s apart, a hold of about 0.8 s at the end of each chapter. Real waits of the product compress to 1 or 2 s.
- The end screen shows breadth (what else the product does, with real UI pieces), then the lockup.
Why: a dense one-take cut with a micro-action every 0.5 s read as polish to its maker and as noise to the reviewer ("too fast, too much at once, overwhelming"); zooming in and out in the results made them impossible to follow. The calm chaptered version was approved.
Every beat is anchored to a word
b = buildlib.Build(__file__)
T, w, end = b.T, b.w, b.end
T["N2"] = T["ch1"] + 0.3 # placing a clip id places that clip
T["rack1"] = T["N2"] + w("N2", "after") - 0.25 # focus returns just before "after"
T["send1"] = max(T["type1"] + T["type1Dur"] + 0.4, end("N3") + 0.15)
w(clip, prefix, nth=1)is the start of the first word beginning withprefix, relative to the clip. It raises when the word is missing (no silent fallback: a fallback that quietly lands a beat at a default time is how motion drifts away from the voice unnoticed).- A UI event leads the word that names it by 0.05 to 0.45 s.
max()keeps both the narration order and "the UI has finished";end(k)isT[k]plus the clip duration (orb.dur(k, d)). - Typing speed is characters per second (about 17 to 21), or fixed to fit a beat.
- Clicks, typing and pops get their sound on the same
T(b.click(t),b.typing(text, t, dur),b.pop(t)), so a retimed beat moves its sound too. - Regenerating one line of voice re-times the whole video. That is the point.
Why: timing has one source of truth. When a line is regenerated, everything anchored to it moves together; hand-typed seconds go stale at the first retake.
Workflow
- Read
BRIEF.md(chapters, framing),COVERAGE.md(what each chapter must explain on screen),specs/*.mdandTRUTH.md. Do not invent UI: a screen or behaviour without a source inSOURCES.mdorspecs/stays out. - Rebuild the screens in
src/template.tplandsrc/app.cssfrom the specs, with fictional data from product.yamlcast. Reuse product pieces fromkit/ui/with{{UI:name}}instead of copying them from another video (references/ui-component-library.md). - Write the beats in
build.pyand the timeline in the template with the kit helpers (references/stage-kit.mdfor the API,references/beat-recipes.mdfor each beat,references/end-screen-and-lockup.md). Follow the seek-safe-motion skill for every tween. - Build a draft and read the beat table it prints:
Before any voice exists,PVS_HOME="$(cd "$(cd "${CLAUDE_SKILL_DIR}" && pwd -P)/../.." && pwd)" "$PVS_HOME/bin/pvs-py" <video_dir>/video/build.py --draft--draft --fake-timingsinvents word timings fromlines.tsvso you can lay out screens. - Lint and check, from
<video_dir>/video/:Expect 0 errors and 0 runtime warnings. One lint warning is expected for the monolithic stage:"$PVS_HOME/bin/pvs-py" "$PVS_HOME/skills/seek-safe-motion/scripts/lint_motion.py" . npx --yes [email protected] checkcomposition_file_too_large(the kit is inlined). Then run the secondcheckthe build prints,check --at <setup beat times>: the default samples often land only on racked moments, where the kit hides product text from the contrast pass (it is blurred on purpose), socontrast ... checked 0there audited nothing a viewer reads.checkalone is not enough: it passed on a video where moving items were invisible for most of their path. Look at frames. - Snapshot every setup beat and look at each image yourself. Mark them with
b.snap(t, label)in build.py; the build prints the exact command:npx --yes [email protected] snapshot . --at 3.2,4.7,9.98 --no-end --describe false -o snapshots/setup--no-endcaptures only those times;--describe falsekeeps frames from being sent to an external vision API. Check the framing (nothing cut at an edge), the cursor on its target, pop-ups centred at natural size, titles whole.--zoom "#toast" --zoom-scale 2crops one element. To test a suspicious tween,--at <start>,<later>must match--at <later>. - Render. A draft for review, then delivery once BRIEF.md is signed (build.py prints this line):
Then run render-qa on the MP4. Keep every version's MP4.npx --yes [email protected] render . -o renders/<video>-v<k>.mp4 --fps 30 --quality draft npx --yes [email protected] render . -o renders/<video>-v<k>.mp4 --fps 30 --quality delivery
The signature gate
build.py refuses to build until coverage_signed_by and claims_signed_by are set in
BRIEF.md (exit 2). --draft builds anyway and stamps <meta name="pvs-draft" content="1">
into index.html; QA marks its report as a draft and delivery refuses it. Do not sign on the
reviewer's behalf, and do not strip the meta: a draft is for looking, never for shipping.
Why: in the production these skills come from, 9 of 26 versions happened because a video did not explain a feature the reviewer cared about, and nobody had written those features down before building. The gate makes the agreement come first.
Rules that come from incidents
- Fix production glitches, keep the look. Keep layout, tokens and behaviour 1:1, but fix visible defects of the live app (missing padding, overflowing or cut text, grey native controls, a focus ring left after a click). List every fix in BRIEF.md notes.
- Show what is configurable when the product is configurable: the settings a buyer would ask about belong on screen, not only in narration.
- Close a menu one frame before opening a modal over it. An open dropdown under a new dialog left ghost text in one render.
- Fade only the end screen content into the lockup, keep its background until the lockup
is up. Fading both showed a frame of sharp UI between them (
S.lockupdoes this). - Hover before the click (
hovclass about 0.12 s before), cursor in after the first rack and out before the end screen. - No vendor or banned names in UI, data or narration (product.yaml
banned_terms,names). If a real screen shows one, keep that piece out of frame, and never leave it visibly empty. - Do not render to show progress early. Iterate with snapshots; render when the setup beats look right.
Files
| Path | Read it when |
|---|---|
references/stage-kit.md | You need a helper's signature, a placeholder, a CSS variable or the buildlib API |
references/beat-recipes.md | You build a specific beat: opening, chapter, click, typing, streaming, pop-up, dropdown, toast, scroll, push-in |
references/framing-and-pacing.md | You choose a framing or a rhythm, or the reviewer says "zoomed", "fast", "chaotic" |
references/end-screen-and-lockup.md | You build the ending |
references/ui-component-library.md | A UI piece will appear in more than one video, or you are about to copy markup from another video |
scripts/buildlib.py | You need behaviour the API reference does not cover |
kit/stage.js | Same, for the timeline helpers |
PVS_HOME/_template/video/video/ | The example build.py, template.tpl and app.css every new video starts from |