Files
hyperframes/examples/docs-reference-project/VERIFICATION.md
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

15 KiB
Raw Permalink Blame History

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 passedok: 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.

  1. pointer_events_none on compositions/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 carry pointer-events: auto, so the editable content is selectable — which is exactly what the finding's own fix hint asks for. Keeping pointer-events: none is correct; the alternative is an invisible full-canvas div that eats every click.

  2. container_overflow on #title at t=0.556, inside span.title-mask. This is the mask doing its job: the title starts at yPercent: 106 and rises into view, so for the first ~0.7s its box is below the mask it is clipped by. Marking the mask data-layout-allow-overflow would silence it, but that attribute is inherited and would also disable text-clipping, content-cramped-container and foreground-over-panel on 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 more link 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.800 only group 0 is drawn (fading out); at 2.867 only group 1 (fading in). Same at 5.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 to opacity: 0; visibility: hidden.
  • Captions clear by 8.033s: sampling the whole caption band at 8.067s gives a darkest pixel of 233 — pure canvas, nothing drawn. The frame then holds a still, caption-free end card through the final frame at 9.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 x translate + 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.wavnpx hyperframes transcribe --model small.entranscript.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.