Files
Ular Kimsanov edfe66a953 docs: add the shared page components and the Reference Project (#2977)
* docs: add the shared page components

Adds the six React snippets the rebuilt documentation pages compose against,
plus the styles they need. Nothing imports them yet, so this lands with no
user-visible change and no navigation churn.

- DocsVideo / ShowcaseWall — the film player and the Showcase grid
- LiveReferenceProject — embeds the Reference Project via <hyperframes-player>
- WorkflowChooser, AgentAction, and the two grid snippets

The scrub indicator is a timecode bubble rather than a thumbnail. Mounting a
second <video> with the same src to drive a preview frame made every page
carrying a film download the whole file twice, which is not worth a thumbnail.

* docs: add the Reference Project example

One real 10-second project the documentation can point at instead of describing
a hypothetical one: a live capture of example.com, synthesised narration, and
caption timings measured from that narration. It passes its own gates —
`hyperframes lint` clean, `hyperframes check` passed, 28/28 text checks WCAG AA.

No page imports it yet, so this lands without touching navigation.

Only the two WAV masters exceed the repository's 500 KB non-LFS limit, so only
those go through LFS. The MP3 stings and the capture PNG stay plain, which keeps
the example usable after a clone without `git lfs pull`.

`bun run docs:bundle-reference` regenerates the single-file embed the
Introduction page loads from the CDN.

* docs: keep the Reference Project verification report

The Examples page links this file twice — as "What changed after review" and
as "The real verification report" — in the section that makes the project's
brief, source, revision notes, and checks public end to end. It is a published
artifact, not leftover scaffolding.

* docs: state the Reference Project embed's isolation contract

The composition is fetched from the CDN and handed to the player as a blob:
URL, which inherits the docs origin, and <hyperframes-player> sandboxes its
iframe with allow-scripts + allow-same-origin. So the embedded composition runs
with script access to this origin.

That is a consequence of how the player works — it drives seeking through the
iframe's document, which a cross-origin frame does not expose — not something
this component can fix. Serving the CDN URL directly would isolate the frame
and break playback.

The guard is therefore the source, so the comment says so out loud: src must
stay a first-party path we publish, never user- or community-supplied HTML.

* fix(docs): resolve reduced-motion on the first render, and the embed's dep gap

Both defects from Rames Jusso's review on #2977. Neither is visible today
because nothing imports these files yet, which is what makes them cheap now.

**Reduced motion resolved one paint too late, in all three grids.**
`useState(false)` plus a `matchMedia` read in an effect meant the first
committed render always emitted `<video src autoPlay loop>`; a reduce-motion
visitor had 6 + 8 + 4 tiles already fetching before the attributes came off.
`autoPlay` also overrides `preload="metadata"`, so those were the files, not
metadata probes — and dropping `src` with no following `load()` is not a
reliable abort. A lazy initializer knows the answer on the first render.

**LiveReferenceProject never sent the initial variables.** The sending effect
read `playerRef.current`, assigned by the effect above it on the commit where
`compositionSrc` lands — a commit with nothing in the sending effect's dep
array. So it ran once against a null ref and never again. It looked correct
only because the three defaults match what the composition already renders.

Also from the same review:

- The object URL could outlive its revoke: once the body resolves, `abort()`
  no longer stops the chain, so the blob could be minted after cleanup ran with
  `objectUrl` still undefined. Same `cancelled` guard the effect above uses.
- `postMessage` targeted `"*"` while the isolation comment argues the frame is
  same-origin. Naming `window.location.origin` turns that prose guard into an
  enforced one.
- Nothing reached a terminal state when the player script never arrived:
  `whenDefined()` does not reject, and a later mount reuses the tag without its
  error listener. A CSP rule or content blocker never fires `error` at all.
  A deadline covers every path instead of sitting on "Loading…" forever.
- `loadFailed` was never cleared, so one transient failure stuck.
- The README claimed a clone works without `git lfs pull`. It does for the
  visuals; both WAVs are pointers and they are the bed and the voiceover, so
  the captions would play over silence. Says so now.
- The bundler stripped trailing whitespace document-wide while inlining the
  runtime, which reaches inside script template literals where those spaces are
  data. It also assumed a literal `<head>` and would silently ship an embed with
  no `<base>`. Strip removed, anchor asserted.

Copilot's five "missing hook imports" comments are wrong — Mintlify pre-injects
the hooks, and `TemplateCard.jsx`, cited as the counter-example, uses the
`export function` form the same page says is unsupported.

* fix(docs): stop preview loops when Reduce Motion is turned on mid-session

Miguel's changes-requested on #2977. He is right about the mechanism: dropping
`src` and `autoPlay` through React props neither pauses a playing element nor
aborts its selected resource, so a visitor who turned Reduce Motion on with the
page already open kept every tile running.

Measured in a browser rather than argued from the spec, same clip, same
sequence:

  playing                 paused=false  t=2.90  readyState=4  networkState=1
  React props only        paused=false  t=3.90  readyState=4  networkState=1
  + pause/removeAttr/load paused=true   t=0     readyState=0  networkState=0

The middle row is the bug: time still advancing, resource still held.

Rames' follow-up asked for a remount-to-poster instead, because a video that
ends with `src` removed holds its last frame and `poster` only paints before
playback begins. `load()` covers that too — it drops readyState to
HAVE_NOTHING, which is precisely the state that paints the poster. Confirmed
side by side on screen: the React-props-only tile sits on an arbitrary mid-clip
frame, the pause/load tile shows the poster again. So no remount is needed.

The guard cannot be shared as code — Mintlify compiles each snippet in
isolation and forbids one importing another — so it is copy-pasted into all
three grids. A duplicated invariant is the kind that rots, and a rendering test
would mean adding React to a repo that only carries it inside packages/studio,
plus mocking Mintlify's hook-injection contract with a mock that can stay green
while the page breaks. `scripts/check-docs-snippet-motion.mjs` asserts the
source instead, wired into `bun run lint`, with unit tests covering both edges.

That gate immediately found `docs/snippets/TemplateCard.jsx`: autoplays with no
reduced-motion handling at all. It is imported by zero pages, and it uses the
`export function` form Mintlify's constraints page says is unsupported, so it
would not work if it were. Deleted rather than fixed.

* refactor(scripts): split the motion guard into named predicates

fallow flagged findMotionGuardViolations at CRAP 42 — a finding this branch
introduced, so it gets fixed rather than suppressed, same as the catalog
generator earlier in the stack.

The two conditions are now their own predicates behind a small requirements
table, which drops the branch count under the threshold and makes each rule
readable on its own line. Same output, same tests.

* fix(docs): move the stop effect above ShowcaseWall's early return

Rames' changes-requested on `e1a03c63`. The effect I added in the previous
commit landed below `if (open) return`, so `ShowcaseWall` called five hooks on
the grid render and four once a tile was open. That is a conditional hook:
clicking a tile — the component's primary interaction — threw "Rendered fewer
hooks than expected".

Worth naming why it landed in one of three. `workflow-chooser` and
`advanced-path-grid` have no early return, so the same paste position was fine
there. `ShowcaseWall` is the only one with a conditional return and it got the
same copy. That is the duplication cost this script's own header warns about,
showing up in the commit that added the script.

**The bespoke gate could not have caught it, and now the generic one does.**
`.oxlintrc.json` already loaded the `react` plugin and never excluded `docs/`
— only `.prettierignore` does, which is why formatting is not a finding here
but linting reaches these files. Naming the two hook rules in an override
scoped to `docs/snippets/**` reports this bug directly, and also reports the
`compositionSrc` dependency gap from round one that was found by reading.
Verified both ways: reintroducing the conditional hook produces
`react-hooks(rules-of-hooks)`, and `bunx oxlint .` is clean repo-wide, so
nothing lit up in `packages/studio`.

**Two holes in the script itself, both from the same review.**

It matched whole files while the invariant is per component, so a second
unguarded grid in `docs-video.jsx` would have ridden in on `ShowcaseWall`'s
guard. It now splits by component. That immediately surfaced the distinction
between a component that decides to autoplay and one that forwards its caller's
`autoPlay` prop — `DocsVideo` only ever plays because a reader clicked, so it
does not owe a preference check.

And `readsPreferenceLazily` never tied its halves: any lazy initializer plus
the media-query string anywhere in the file passed, which is the original bug
satisfying the check written to prevent it. The query now has to sit inside the
initializer's own expression.

Both holes have tests. fallow is clean at 0 introduced.

* fix(scripts): close the two silent gaps in the motion gate

Both from Rames' approval pass on #2977, and both found by running these
functions rather than reading them. Both fail the same quiet way: a component
`autoplays` misses is filtered out before any requirement runs, so the gate
reports zero problems instead of a violation.

`autoplays` had become narrower than the version it replaced. Excluding the
`autoPlay={autoPlay}` passthrough was right, but the replacement only matched
`autoPlay={` or `autoPlay` alone on a line, so `<video autoPlay muted />` on one
line slipped through. Restored the old breadth. Two things are stripped first
rather than one — the passthrough, and the prop's own default in the signature,
which is a declaration and not a use. Without the second strip, `DocsVideo` is
asked to own a decision it only forwards.

`splitComponents` anchored on `^export`, so anything not exported folded into
the previous exported component and inherited its guard. Same hole as the
whole-file match, narrowed from file scope to non-export scope. The anchor no
longer requires `export`.

Ten tests now, including his exact examples for both.

* docs: remove the live-composition embed and its build apparatus

The Introduction no longer carries the embed (removed in #2979), and nothing
else used any of this: the 200-line snippet, 26 CSS rules, the bundler that
built the single-file HTML for the CDN, its npm script, and the README section
explaining how to regenerate it.

The Reference Project itself stays — Examples, Developers, and Go further all
link to it as the worked example; only the interactive embed of it is gone.

This also retires the isolation contract I documented two rounds ago. That
comment existed because the embed handed CDN HTML to a same-origin blob; with
the embed gone there is no such surface to reason about, which is a better
outcome than a comment explaining why it was acceptable.

* docs: remove the AgentAction snippet

Its only consumer is gone. The Quickstart now shows the agent instruction in a
plain fence instead, because this component rendered a Copy button and never
displayed the request — a reader copied text they could not read, which is the
wrong shape for the one affordance a non-technical visitor depends on.

Mintlify fences already carry a copy button and show their contents.
2026-08-04 00:37:48 -07:00
..

example.com — 10-second product intro

The HyperFrames docs Reference Project. One real project, built end to end from one real request:

Using /hyperframes, make a 10-second product intro for https://example.com.

1920×1080 · 10.000s · 30 fps · 300 frames · one scene · one caption overlay.

Everything in here is real: the plate is a live capture of example.com, the copy is the page's own wording, the narration is synthesised speech, and the caption timings are measured word timings from that narration. Nothing is mocked, and no command output in these files is invented.


Run it

bun run dev      # Studio preview (long-running — keep it in the background)
bun run check    # lint + runtime + layout + motion + contrast, one command
bun run render   # → renders/video.mp4

The scripts pin an exact CLI version so this project re-renders identically over time. To move the pin up: npx hyperframes@latest upgrade --project . --check, then drop --check to apply, then re-run bun run check.

Files

File What it is
index.html the composition — root timeline, the whole scene, all four audio tracks
compositions/captions.html the caption overlay, wired as a sub-composition on track 2
BRIEF.md the confirmed intent the workflow was handed
frame.md the design spec — palette, type, the plate rule, the caption skin
STORYBOARD.md the plan: ## Video direction plus one time-coded shot sequence
SCRIPT.md locked narration, the voice, and the exact commands that regenerate it
VERIFICATION.md what passed, what changed from v1, and the two framework bugs found on the way
transcript.json Whisper word timings for assets/narration.wav — the source of every caption cue

The five variables

Declared on <html> as data-composition-variables, so the same composition can front another site without touching the HTML:

id type default
title string Example Domain
accent color #334488
supportingLine string For use in documentation examples without needing permission.
siteUrl string example.com
pageImage string assets/example-com.png

They are wired declaratively — no getVariables() call anywhere in this project:

<h1 data-var-text="title">Example Domain</h1>
<!-- text substitution -->
<img data-var-src="pageImage" src="assets/example-com.png" />
<!-- src substitution -->
background: var(--accent, #334488); /* the runtime publishes every scalar variable
                                      as a --<id> custom property on the root */

Override at render time:

npx hyperframes render --variables '{"title":"Acme Docs","accent":"#0f766e"}'
npx hyperframes render --variables-file rows.json --strict-variables

Two gotchas worth knowing, both learned here:

  1. Keep the data-composition-variables JSON pure ASCII. It sits on <html>, which is consumed before <meta charset> takes effect, so a literal em dash in a default renders as â€". Use the JSON escape \u2014 instead — verified by snapshot. Body text and sub-composition text are unaffected.
  2. Put data-var-src before src. lint's missing_local_asset scan matches the last src= in the tag, and data-var-src also ends in src, so the reverse order makes it read the variable id as a filename and fail.

Audio

Four tracks, each a plain <audio> element — the framework owns playback, so this project never calls play(), pause() or seeks.

Track Element Asset Baseline Notes
8 #bgm bgm.wav 0.34 ducked / recovered / faded on the timeline
9 #vo narration.wav 1.0 starts at 1.00s, runs 6.97s
10 #sfx-plate sfx-whoosh.mp3 0.20 at 2.62s, under the plate's travel
11 #sfx-marker sfx-tick.mp3 0.16 at 4.54s, on the marker draw

Both WAVs are Git LFS pointers, so run git lfs pull before rendering. Without it the bed and the voiceover are 130-byte stubs and the captions — timed from narration.wav — play over silence. The two MP3 stings and the capture PNG are stored plainly and need no extra step.

The bed is not a static level. Volume is keyframed on the timeline, which the runtime probes and applies identically in preview and render:

tl.to("#bgm", { volume: 0.13, duration: 0.6 }, 0.7); // duck under the voice
tl.to("#bgm", { volume: 0.3, duration: 0.9 }, 7.7); // recover after the last word
tl.to("#bgm", { volume: 0, duration: 1.0 }, 9.0); // out under the end card

data-volume is only the baseline for elements no tween touches.

Captions

compositions/captions.html, mounted on track 2 for the full 10s. It follows the captions overlay doctrine literally, and the discipline is worth copying:

  • Three groups for three spoken sentences. Phrase-level, not word-level churn.
  • Fixed position, always. One full-width absolute container, text-align: center, bottom: 64px. No left: 50% + translateX(-50%) (it clips at canvas edges), no per-group placement, no random offsets.
  • A structurally reserved band. index.html's stage ends at bottom: var(--band) (184px = 17%), so nothing in the artwork can ever collide with a caption. Verified with check --caption-zone "x0=0;y0=.83;x1=1;y1=1;severity=error;…".
  • One group visible at a time, provably. Group n's hard kill sits at the exact time group n+1 starts, so before that instant only n can be non-zero and after it n is killed outright.
  • Emphasis by luminance only. Each word lights #9a9a9a → #ffffff on its own measured onset and stays lit, so the phrase fills in with the voice. No scale pop, no colour flash, no scatter exit, no marker effects, no labels.
  • fitTextFontSize as the overflow guard, so an edited or translated phrase shrinks instead of wrapping up out of the band.

Media provenance

The project keeps only the assets used by the composition:

Shipped asset Source
assets/example-com.png npx hyperframes capture https://example.com
assets/narration.wav ElevenLabs River, eleven_multilingual_v2
assets/bgm.wav HeyGen audio catalog, 10.0-second bed
assets/sfx-whoosh.mp3 /media-use bundled library — whoosh-short, 0.57 seconds
assets/sfx-tick.mp3 /media-use bundled library — click-soft, 0.37 seconds

Generated capture folders, frame dumps, contact sheets, and compiled docs embeds are not committed. The docs copy of the verified render is served from the HyperFrames media CDN.

Learning path through the composition

Read index.html top to bottom; it is ordered deliberately:

  1. data-composition-variables on <html> — the parameters.
  2. #root custom properties — the palette, and the --band reservation.
  3. Track 0 #bg — why a full-bleed fill rides on a clip layer and never on #root.
  4. Track 1 #stage — a two-column flex stage that stops above the caption band.
  5. .pageobject-fit: none + object-position, the 1:1 plate rule.
  6. Track 2 — the sub-composition host, and the three ids that must match exactly.
  7. The <audio> elements — one per track, <video>-free, framework-owned.
  8. The timeline — load states first, then one tween per narration cue, with the two deliberate holds left visibly empty.