* 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.
15 KiB
Verification report — v1 → v2
Project: examples/docs-reference-project · 1920×1080 · 10.000s · 30 fps · 300 frames
CLI: hyperframes@0.7.90 (project pin bumped from 0.7.88 during this pass and
re-verified — see "Toolchain" below).
1. Gate results
All commands were run in the project directory. These are the actual results.
| Gate | Result |
|---|---|
npx hyperframes lint --verbose |
0 errors, 0 warnings (2 files scanned) |
npx hyperframes check |
passed — ok: true |
| › lint | 0 errors · 0 warnings · 1 info |
| › runtime | 0 errors · 0 warnings · 0 info |
| › layout | 0 errors · 0 warnings · 1 info |
| › motion | 0 findings (no *.motion.json sidecars in this project) |
| › contrast | 0 errors · 0 warnings · 0 info |
check --caption-zone "…y0=.83…severity=error" |
passed — 0 caption-band collisions across 8 seek points |
npx hyperframes snapshot --at … (19 frames) |
captured + inspected; 3 contact sheets |
npx hyperframes render |
passed — H.264 1080p + AAC stereo, exactly 10.000s |
Final media inspection: 1920×1080 H.264, 30 fps, AAC stereo at 48 kHz, 10.000 seconds. Mean volume is −22.9 dB and the peak is −3.8 dB.
The two info-level findings, and why they stay
Neither gates the exit code. Both are deliberate.
-
pointer_events_noneoncompositions/captions.html→#root. The caption overlay spans the whole canvas above the artwork, so its root must be click-through or nothing underneath is selectable in Studio. The pills themselves carrypointer-events: auto, so the editable content is selectable — which is exactly what the finding's own fix hint asks for. Keepingpointer-events: noneis correct; the alternative is an invisible full-canvas div that eats every click. -
container_overflowon#titleatt=0.556, insidespan.title-mask. This is the mask doing its job: the title starts atyPercent: 106and rises into view, so for the first ~0.7s its box is below the mask it is clipped by. Marking the maskdata-layout-allow-overflowwould silence it, but that attribute is inherited and would also disabletext-clipping,content-cramped-containerandforeground-over-panelon the hero title for the whole composition. A transient info finding is the cheaper price. The finding is reported once, at one sample.
What "inspected snapshots" means here
19 frames on the 30 fps frame grid (not arbitrary decimals), chosen to cover every beat plus both caption cuts and the caption clear:
0 · 0.533 · 1.3 · 1.967 · 2.6 · 2.8 · 2.867 · 3.133 · 4.5 · 5.0 · 5.433 · 5.5 · 5.733 · 6.4 · 7.1 · 7.933 · 8.067 · 9.5 · 9.967
The root declares data-fps="30", so those are real frame times. Requesting an
off-grid time (e.g. 2.833) quantises to the nearest frame and renders 2.800 twice —
which briefly looked like a caption bleed until it was measured. It was not one.
Checked and confirmed:
- The plate lands at 1:1 and the page's real 16px body copy is legible — the headline,
the full sentence, and the
Learn morelink all read. - The accent marker draws beside the page's own paragraph without touching the link below it (6px clearance, measured).
- Both caption cuts hand off cleanly. At
2.800only group 0 is drawn (fading out); at2.867only group 1 (fading in). Same at5.433/5.500. This also holds by construction: group n's hard kill sits at exactly group n+1's start, so before that instant only n can be non-zero and at/after it n is set toopacity: 0; visibility: hidden. - Captions clear by
8.033s: sampling the whole caption band at8.067sgives a darkest pixel of233— pure canvas, nothing drawn. The frame then holds a still, caption-free end card through the final frame at9.967s. - No black frame, no blank panel, no clipped text, no element in the caption band.
snapshot's optional Gemini frame-description pass failed (API key not valid) — the
ambient GEMINI_API_KEY is rejected. That is an optional annotation, not a gate; the
empty descriptions.md was deleted rather than shipped as a wall of identical errors.
Frames were inspected directly.
2. v1 → v2
v1 is the verified original at quickstart/example-intro: same request, same source,
same 10.0s / 1920×1080 output. v2 keeps its concept — show the site as it is, the real
page is the proof — and its split composition. What changed, and why.
2.1 The capture actually reads now — the one that mattered
v1's biggest defect was invisible in the source and obvious in the render. The capture was 1440×810 displayed inside a 940×529 card: 0.65×. The page's 16px body text rendered at roughly 10px in a 1080p frame, so the "real captured page" — the entire argument of the video — was an unreadable grey smudge with a tiny cluster in one corner.
v2 shows the capture at exactly 1:1. A fresh 1920×1080 1x capture is displayed
through a 1000×400 window with object-fit: none; object-position: -300px -104px, so the
page's own type renders at the size it renders at in a browser. Three hard rules fall out
of that, and they are written into frame.md:
- the plate never scales (a 1x capture has no headroom above 1:1),
- the plate never rotates (v1 tilted it
rotationY: -10° → -4° → -1.5°, resampling the page text for the entire shot), - the entrance is
xtranslate + opacity only.
object-fit: none also means the <img> box is exactly 1000×400 instead of a 1920×1080
element hanging out of an overflow: hidden parent, so it needs no
data-layout-allow-overflow and trips no layout finding.
2.2 Decorative noise removed
| Removed from v1 | Why |
|---|---|
| 160px background grid and 4px dot "paper" | Two textures stacked under a third layer (the light wash). Visible busywork on a flat page. |
The sheen sweep across the card (7.55 → 8.65s) |
A stock shine gimmick. It decorated the evidence instead of reading it. |
The ambient accent bloom (5.0s, then scale 1.05) |
Fired 2.5s after the plate landed, cued to nothing. |
https://example.com caption under the card |
A label repeating the example.com line already on screen, in near-invisible grey. |
The Learn more → pill button |
Invented UI. The page's action is a plain text link — and it is already visible in the plate, as itself. |
The 1.9s rotationY "settle" + glow scale at the end |
Lazy breathing. v2 holds completely still instead. |
What replaced them is one device with a job: a single 5px accent marker that draws down the left edge of the page's own content block, on the narration cue "…documentation examples." It is the film's argument — those are the page's words, not ours — and it is the only new graphic element in v2.
2.3 Narration, captions and pacing (new in v2)
v1 was silent except for a music bed, so nothing cued anything; its beats were spaced by
feel (0.1 · 0.45 · 1.55 · 2.5 · 3.9 · 5.0 · 6.5 · 7.55).
v2 has narration, and every visual cue is a measured word timing, not an estimate:
assets/narration.wav → npx hyperframes transcribe --model small.en →
transcript.json → the tween positions in index.html and the group boundaries in
compositions/captions.html. Three phrase captions for three sentences, fixed position,
one visible at a time, per-word emphasis by luminance only.
Pacing consequence: v1 front-loaded the lockup then idled with decoration. v2's reveals
land at 1.00 · 1.55 · 1.78 · 2.66 · 4.55 · 6.42 — the last one inside the final 30% —
with two deliberately empty holds (5.15 → 6.42 mid-film breather, 8.03 → 10.0
still end card). The holds are left visibly empty in the timeline source, with comments
saying so, because a held read beats bad motion.
2.4 Determinism fix: non-embedded font weights
v1 asked for font-weight: 500 on IBM Plex Mono and 600 on Inter. The renderer embeds
inter at 400/700/900 and ibm-plex-mono at 400/700 only, so both requests were
synthesised or substituted on a clean render machine — preview and output could disagree.
v2 uses only bundled weights: Inter 900 (title) / 400 (body), IBM Plex Mono 400
(address), Inter 700 (captions). frame.md states the rule so an editor cannot
reintroduce it.
2.5 Parameterisation (new in v2)
v1 had no variables — it was a one-off. v2 declares five with useful defaults and wires
them declaratively (data-var-text, data-var-src, var(--accent)), with no
getVariables() call anywhere, so the composition can front another site by flags alone.
2.6 Palette: one derived value, stated as derived
v1's brief claimed the palette was read from the capture, then set the canvas to
#eeeeee — the page's own background. The plate therefore had the same fill as the
ground and only its border separated them, which is why it read as a faint rectangle.
v2 keeps ink #1b1b1b and accent #334488 from the captured page (its CSS literally
says a { color:#348 }) and steps the canvas down to #e9e9e7
so the plate has something to lift off. frame.md labels that as the one derived value
rather than pretending it was read.
2.7 One thing v2 gives up
v1's title was two hand-split <span>s waterfalling in 0.17s apart — a nicer reveal than
v2's single masked rise. That split is incompatible with data-var-text, which replaces
an element's own text and cannot drive per-line spans. v2 trades the waterfall for a
title that is actually a parameter. Called out here because it is a real regression, not
an oversight.
3. Two framework bugs found, with reproductions
Both were hit while building this project and both are fixed in this project. Neither is a blocker for it.
3.1 window.getComputedStyle() throws inside a sub-composition
Severity: high — the failure is near-silent and ships broken video.
The captions doctrine (media-use/audio/references/captions/authoring.md → "Self-lint
after building timeline") prescribes this snippet verbatim:
var computed = window.getComputedStyle(el);
Inside a sub-composition it raises TypeError: Illegal invocation. The script dies
mid-self-lint, so window.__timelines["captions"] = tl never runs, the runtime waits out
its registration timeout, and the render captures whatever DOM state the throw left
behind. Observed symptom: caption group 0 frozen at ~14% opacity for the whole video,
with check reporting it only indirectly as 30 contrast_aa_failure errors against a
background of rgb(205,205,203) — a colour that exists nowhere in the design.
Root cause, measured from inside a sub-composition script:
window === globalThis → false
window.getComputedStyle === globalThis.getComputedStyle → true
window.__hyperframes.fitTextFontSize → function
window.__timelines → object
The runtime evaluates a sub-composition's inline script with a window wrapper
object. Property reads and writes proxy through to the real window, which is why
__timelines and __hyperframes work. But retrieving the same native function through
the wrapper and calling it as a method makes the wrapper the receiver, and native code
rejects it.
Fix in this project (compositions/captions.html), with the reason in a comment:
var computed = getComputedStyle(el); // bare — not window.getComputedStyle
globalThis.getComputedStyle(el) also works. Suggested upstream actions: make the
wrapper bind native Window methods, and fix the snippet in the captions doctrine —
every agent that follows it inside a sub-composition inherits this bug.
3.2 lint's missing_local_asset mis-parses data-var-src
Severity: low — loud, harmless, one-line workaround.
<img src="assets/example-com.png" data-var-src="pageImage" />
fails lint with missing_local_asset: <img> element references local file(s) not found in the project: pageImage. The rule's regex is
/<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi; the greedy [^>]*
takes the last src= in the tag, and data-var-src ends in src with a - before
it, so \b matches and the variable id is read as a filename.
Workaround used here: author data-var-src before src. Suggested upstream fix:
require a whitespace or " boundary before src ((?<=[\s"'])src\s*=), or explicitly
skip data-var-src.
4. One documented authoring constraint
Not a bug, but a real trap worth stating: keep data-composition-variables pure
ASCII. It lives on <html>, which is consumed before <meta charset> is in effect, so
a literal em dash in a default renders as â€". Verified both ways by snapshot: the
literal character mojibakes, the JSON escape \u2014 renders a correct em dash. Text in
the document body and inside a sub-composition <template> is unaffected (the runtime
fetches sub-compositions and decodes them as UTF-8).
v2 ships ASCII-only variable defaults and states the rule in a comment in index.html.
5. Toolchain
The project's package.json pinned hyperframes@0.7.88. Per the CLI's own upgrade
protocol the pin was probed before any render-affecting command:
npx hyperframes@latest upgrade --project . --check
→ would bump project scripts 0.7.88 → 0.7.90
Applied, then verified: npx hyperframes check passes on 0.7.90. A passing check
confirms the compositions still validate on the new version — not that output is
frame-identical to the old pin. The project now runs on 0.7.90; hyperframes info
reports updateAvailable: false.
6. Blockers
None. lint and check pass with zero errors and zero warnings, snapshots are
captured and inspected, and the final MP4 passed the media gate.
Two non-blocking environment notes: the ambient GEMINI_API_KEY is rejected by the API,
so snapshot's optional vision descriptions are unavailable; and hyperframes feedback
was not sent, because the CLI's protocol sends it only after verifying a successful
render. Both framework findings in §3 are written up here in reproducible form so they
can be filed with that render.