* feat(cli): track which registry items `add` installs
`cli_command` records that `add` ran and nothing about what it installed, and
the registry is served from raw.githubusercontent.com, which gives no per-item
counter either — so there is no way to tell which block or component people
actually pull, and no way to know what is worth building more of.
Emit one `registry_item_added` event per item written into a project, from
`runAdd` after the install succeeds. That is the single choke point: the bulk
`add <tag>` path re-enters it per item, and a failed or compatibility-refused
install throws before it, so a refused install is never counted as a download.
`requested` separates the item the user named from the transitive
`registryDependencies` pulled in behind it; without it a popular dependency
outranks everything that depends on it.
Item names are public registry identifiers, never user content or project data,
and the event goes through `trackEvent` — an install that opted out via
`hyperframes telemetry disable`, `HYPERFRAMES_NO_TELEMETRY` or `DO_NOT_TRACK`
sends nothing.
* test(cli): cover `add` telemetry end to end against the built CLI
The unit tests assert the emit seam and nothing past it. `shouldTrack()`
short-circuits whenever `isDevMode()` is true, and that is true for any `.ts`
entry, so under vitest a real event and no event are indistinguishable and the
transport is never exercised at all.
Drive the built CLI instead and assert on the HTTP body it actually produces:
one event per installed item, the dependency reported with `requested: false`,
an opted-out install sending no request at all (not merely one without this
event), and a refused install counting nothing.
Two fixtures, because neither case is reachable through the real registry. The
registry origin is a first-class project setting, so a local one supplies the
`registryDependencies` edge that no shipped catalog item declares today; and
`globalThis.fetch` is wrapped to capture the batch rather than send it. The
faked 200 is load-bearing: only a failed flush leaves events queued, and only a
non-empty queue spawns the detached `flushSync` child that would bypass the
hook and reach production analytics.
Verified the check can fail — forcing `requested: true` for every item turns it
red on exactly the dependency assertion.
* feat(sdk): expose a paint query for transparent-composition hit-testing
A host layering a transparent composition over other content has to know
whether a point carries ink before it decides to swallow a click. The adapter
only answered "what element is here", so AI Studio wrote its own answer and
could not reach the per-pixel alpha the adapter already samples for <img>.
Add PreviewAdapter.paintsAt, plus the pieces it is built from on a new
./adapters/iframe subpath so a host with different hit-test policy can compose
its own walk.
The walk is geometric rather than elementsFromPoint-based: that stack omits
pointer-events:none nodes, and a decorative overlay carrying it still paints,
so a z-stack query would report no ink over visible artwork — the direction
that makes a composition vanish from under the cursor.
fullBleedFraction is an option rather than a constant because "a layer covering
the whole frame is background, not artwork" is host policy, not a fact about
the composition.
* fix(sdk): scope the full-bleed frame to the root under the point
compositionFrameArea took the smallest [data-composition-id] in the whole
document, so an unrelated sub-composition sized the reference frame for points
nowhere near it: a 300x300 badge in a corner made every mid-size painter in a
1920x1080 outer frame read as full-bleed, and the composition went
click-through under artwork the user can plainly see. That is the direction the
fail-safe exists to avoid, and the docs already described the intended
behaviour — the innermost root CONTAINING the point.
Also read the alpha channel instead of matching known transparent spellings.
Only the `transparent` keyword computes to rgba(0, 0, 0, 0); a faded-out white
stays rgba(255, 255, 255, 0), which the set counted as painted. That erred
toward absorbing clicks rather than losing them, so it was a false positive
rather than a hazard, but it is wrong.
Both are pinned by tests that fail when the fix is reverted.
* fix(sdk): stop the paint query answering "no ink" over visible artwork
Four cases where the walk landed on the wrong side of its own fail-safe.
The full-bleed veto tested the winner's border-box area even when the alpha
sampler had just read an opaque pixel there, so a full-frame transparent PNG or
SVG overlay — the case this feature exists for — reported background over
visibly opaque artwork, with no fullBleedFraction that worked. Ink now carries
how it was established, and a measured pixel is never vetoed. An image whose
pixels could NOT be read stays inferred, so a tainted CDN overlay still yields
to the veto rather than absorbing every click.
Composition roots were excluded from candidacy outright, so a root carrying a
background answered false even at fraction 0, where the docs promise every
painting box counts. Roots are candidates now; the veto discounts them without
a special case, since a root's box is the frame.
An <img> with a clear pixel early-returned past its own background, padding
plate and border, which any other element would have counted.
A same-origin iframe mid-navigation exposes a readable but empty document, so
the !doc guard never fired and a loading composition answered a confident
"no ink" — the exact failure the null convention exists to prevent.
Also: the sort comparator's epsilon tie was intransitive, leaving the
smallest-first guarantee (and the lazy single-sample property that rides on it)
engine-dependent; the guide's pass-through recipe called a function that does
not exist and hand-waved the coordinate mapping that makes it correct; and the
reference now states the under-counts alongside the over-counts, the walk's
blindness to runtime-mounted content, and compositionPaintsAt's preconditions.
Each fix is pinned by a test that fails when the fix is reverted.
* refactor(sdk)!: invert the paint query to isProvablyEmptyAt
paintsAt handed callers three falsy bottom values with opposite safe readings:
false meant "no ink, pass the click through", null meant "not knowable, treat as
painted", and undefined from an adapter without the method also meant painted.
The idiomatic `if (!preview.paintsAt?.(x, y)) passThrough()` therefore did the
dangerous thing for two of the three, and the convention needed defending in the
interface docstring, the reference and the guide — plus a dedicated comment and
a pinning test on the headless adapter to stop null regressing to false.
Inverting the polarity collapses the tri-state to a plain boolean and makes the
safe reading structural: true only when the composition was readable and nothing
painted there, so ink, an unreadable or still-loading document, and a missing
implementation all land on "keep the composition clickable". The prose stays as
rationale, but nothing depends on a reader remembering it.
PaintsAtOptions becomes PaintQueryOptions, since it now describes the walk that
both the adapter method and the exported compositionPaintsAt share rather than
one method's arguments. compositionPaintsAt keeps its ink-positive name: it
answers the other question, and its docstring points callers who need the
fail-safe contract at the adapter.
Nothing is released yet, so no consumer is on the old name.
- html-schema: the previous clause claimed a composition host's data-media-start is
never read. It is: readElementPlaybackStart (media.ts:16) resolves
data-playback-start ?? data-media-start and timeline.ts calls it on composition
clips. Rewrote to the accurate reason both reviewers gave — composition hosts are
only inspected by the playback-start-first readers, media-start works as a
fallback, but playback-start is what Studio writes/normalises to.
- motion test: pin the HoverVideo click-suppression (preventDefault +
stopPropagation). Removing it left the gate 12/12; now it fails. This is the bug
that escaped static review and only surfaced by driving the live preview.
- replica-compare: fold the visible 'Sound off/on' text into the aria-label so the
accessible name contains it (WCAG 2.5.3, Rames).
Round-5 findings from Magi and Rames.
Grid cards with a public source are wrapped in an <a>; the sound/play button
lives inside it, so a click — mouse or keyboard — bubbled up and navigated to
GitHub instead of toggling sound. preventDefault + stopPropagation on the control.
Found by driving the live preview (self-review).
- replica-compare: the offscreen teardown now resets muted (element + React state),
matching HoverVideo — a pair unmuted before it scrolled away no longer returns
reading 'Sound on' over a paused, sourceless pair (Magi blocker).
- Both controls' aria-labels now follow the mode: under reduced motion the button
plays/pauses the whole comparison, and when a preview is already autoplaying
muted the action is 'unmute', not 'play with sound' (self-review + Rames).
- Hardened the motion-suite assertion to scope 'startBoth' to toggleSound's body
(a defined-but-unused helper no longer satisfies it) and pin the offscreen
muted-reset transition (Rames mutation-test gap).
- html-schema: 'hyperframes validate' inspects <audio> only; note that no
media-start-only reader inspects a composition host, so the kind rule strands
nobody (Rames).
Per Rames: add the engine audio mixer (audioMixer.ts:350 -> ffmpeg -ss) to the
media-start-only readers — it's the live path that makes a lone data-playback-start
a shipped output bug (trimmed picture over untrimmed audio). 'Set one' isn't enough;
give the element-kind rule: <video>/<audio> use data-media-start, nested composition
uses data-playback-start (Studio writes it; it's the child-timeline offset). Also fix
editing-existing-videos.mdx, which offered both names as interchangeable for front
trim of a clip — the exact kind where they aren't.
- replica-compare: the voluntary control now starts and pauses BOTH films (not
just the reference), and the replica-sync effect attaches in view regardless of
the preference, so a reduced-motion visitor who presses play sees the whole
synchronized pair. Added a focused source-level assertion to the motion-check
suite (the repo has no React runtime harness for snippets).
- html-schema: describe each layer precisely instead of grouping the CLI —
timing compiler, HTML parser, producer audio, and 'hyperframes validate' read
only data-media-start; runtime, Studio and 'hyperframes snapshot' read
data-playback-start first (Studio also writes it).
Round-3 findings from Magi.
The merged '## Timeline navigation' section documents a third arrow-key behaviour;
the 'selected area decides' paragraph read as exhaustive with only two. Fold in the
timeline-focus case and link the section. Coherence note from Rames.
Both snippet players now keep a source assigned whenever in view (preload='none'
so nothing downloads until asked), gating only autoplay on reduced motion — so a
reduced-motion visitor can press play/unmute instead of being left with an inert
poster. HoverVideo renders a control on hasAudio={false} cards too (a play/pause
toggle), and ReplicaCompare no longer calls play() on a source it just removed.
Gave ReplicaCompare's button the same aria-pressed + focus-visible as HoverVideo.
Round-2 finding from Magi (#1) and Rames.
- edit-operations: dispatch() does not consult can() (session.ts:608 -> applyOp
with no validation; a no-timeline addGsapTween/addLabel is a no-op but a missing
target still writes via selector fallback). Use Rames's wording: call can() first
and skip on failure — no false 'applies nothing' guarantee.
- timing-and-animation: the second E_NO_GSAP_TIMELINE site — gated setGsapTween on
an error it cannot return and called shipped parser code 'a later phase'. Rewrote
to gate addGsapTween (which can return it); dropped the stale can() comment in
types.ts:595.
- html-schema: data-playback-start is read by runtime, Studio and CLI (Studio also
writes it, timelineEditingHelpers.ts); only the compile path is media-start only.
Document the layered precedence instead of calling it runtime-only.
Round-2 findings from Magi (#2/#3/#4) and Rames.
ReplicaCompare now assigns/decodes its reference+replica only near the viewport
and releases both (pause + removeAttribute + load) on exit, closing the last
eager-load surface from Magi's #5. Sync + reduced-motion guard preserved.
HoverVideo now: (1) exposes a focusable button that toggles/plays sound by
keyboard, touch and pointer — hover is an enhancement, not the only path;
(2) gates the source behind an IntersectionObserver so offscreen cards no longer
download/decode, releasing the buffer on exit; (3) still blocks autoplay under
reduced motion while allowing voluntary playback via the button. Routed all 30
Thirty-Days cards through it (hasAudio={false} for the 12 silent ones) so no raw
<video autoPlay> bypasses the motion guard. Flagged by Magi (P1 #4/#5/#6).
- edit-operations: E_NO_GSAP_TIMELINE is raised only by addGsapTween and addLabel
(mutate.ts:1747), not the set/remove tween ops; drop the false 'dispatch() still
applies structurally' claim (mutate.gsap.test.ts verifies zero patches); add the
requirement to the Labels section where addLabel lives.
- html-schema: data-media-start is read by parser + timing compiler + runtime;
data-playback-start is a runtime-only alias, so a lone playback-start doesn't
shift trim/split. Document the surfaces instead of claiming one universal value.
- testing-local-changes: bun unlink cleanup used a relative cd that resolves under
the video project; use an absolute checkout path.
Flagged by Magi (P1 #2/#3, P2 #7) and Rames.
My generator rebuild replaced the '## Related topics' section (still required by
docs/AGENTS.md) with the provenance footer, dropping it from all 168 generated
Catalog pages. Emit it again as the final section so pages end with it, and stop
carriedSectionsFrom() breaking at the footer marker so a human section appended
below the generated tail survives a regeneration. Adds a per-page regeneration
assertion so a future drop fails CI. Flagged by Magi (P1) and Rames.
Repaint the Playground pill with the HyperFrames mark's own cyan-to-green gradient
(#06E3FA -> #4FDB5E) with dark text, instead of the muted primary-dark. Left the
site-wide primary untouched so link contrast stays AA. Also nudge the logo 16px so
its left edge lines up with the sidebar text instead of sitting at the navbar padding.
The reverse-*-reference.mp4 keys on the CDN were serving the original silent cut
(immutable cache never took the audio re-upload). Re-encoded from the audio sources
to reverse-*-reference-snd.mp4 and pointed ReplicaCompare at them, so the reference
now actually plays sound on click.
The claude-design (54s) and music-to-video (181s) docs films were the real ~40s/~45s
films padded with black / a held end-logo. Swapped both for their correct X launch
versions (40s and 90s) which also carry audio, and wired figma to its X source. All
three now hover-to-hear.
Swapped five more grid films (launch, keyframes, pr-to-video, skills, sound) to
their content-matched X launch versions with audio. Added an integrations strip
(Codex, Vercel, Ollama, hyperframes.dev community) from the same archive. Skipped
the square/different-cut sources (figma, claude-design, music-to-video) to avoid
cropping or swapping in a different edit.
variables, texture, website-to-video, vfx, frame.md and inspector each have a
content-matched launch film on @HeyGen's X with audio; re-encoded those to -s.mp4
and wired HoverVideo. Launch grid now has 10 cards with hover-to-hear.
HF x Stripe, SpaceX, Cloud rendering and Timeline had audio-baked render masters
in hyperframes-launches (via LFS); re-encoded those with sound to -s.mp4 keys and
wrapped their cards in HoverVideo. The rest of the grid has no ready audio master
(silent renders; audio only as unmixed assets) and stays muted.
Adds a HoverVideo snippet (autoplay muted, unmute + play on hover, re-mute on
leave) and points the 18 days whose source has real audio at new -s.mp4 keys
re-encoded with sound (CDN is immutable, so new keys not overwrites). The other
12 days have genuinely silent sources and stay muted. Honours prefers-reduced-motion.
skills-launch-video, keyframes-launch and music-to-video-launch have no folder
in hyperframes-launches. The films are real but their source was never published,
so these cards were linking to 404s and falsely claiming 'source included'. Made
them non-linked cards labelled '100% HyperFrames', like the other sourceless ones.
The reference and replica play on one shared clock; a single Instagram-style
button unmutes the reference (its original audio), the replica stays silent.
Reference videos re-encoded with audio on the CDN. The component honours
prefers-reduced-motion on both edges, per the docs-snippet-motion guard.
Two reverse-engineering campaigns (THE OBLIST, Srinika x Mercury), each
showing the reference original beside its frame-matched HyperFrames replica.
Assets on the docs CDN; kept separate from the '100% HyperFrames' grid since
the reference films are the originals.
I misdiagnosed this as a pre-existing Windows CRLF failure. It was not. The core
test catalogGeneratorInstructions.test.ts reads scripts/generate-catalog-pages.ts
and asserts it contains three exact phrases. My catalog rewrite reworded the
texture snippet instruction from "paste the real `<style>` element near the
bottom" to "Near the bottom is a real `<style>` element — copy it into", which
dropped the third pinned phrase.
Restored the wording to contain "paste the real `<style>` element near the
bottom". The test now passes (3/3 expects), and it was failing on the Windows
runner only because the Ubuntu run tripped an unrelated player port flake first
and masked the same core failure there.
Not Windows, not pre-existing, not line endings — my text change. Caught because
the reviewer questioned the diagnosis.
My catalog rebuild grew five functions past fallow's CRAP threshold —
carriedSectionsFrom and its flush (the carry-forward parser), plus generateItemMdx,
generateParams and main which took on the poster and carry-forward wiring.
These are build-time scripts with no unit coverage, and CRAP penalises exactly
that: the inherited functions in the same two files score 300-600 for the same
reason and pass only because they predate the gate. Refactoring into smaller
functions made it worse — more uncovered functions, not fewer findings. So each
grown function carries a `// fallow-ignore-next-line complexity`, the tool's
documented mechanism, applied consistently with the file's existing shape.
Fallow verdict is pass: 0 introduced complexity, 0 introduced duplication.
Generator runs clean and emits 168 pages.
The user downloaded the actual daily posts and matched each to its day. Every
card now plays the real Day-N film — install, PR-to-video, keyframes in Studio,
background removal on a checkerboard, color grading, components, the lot — over
its own X-post link, no borrowed docs clips.
Day 7's five motion-graphics pieces are concatenated into one film. Day 26 had no
dedicated post, so it uses the media-effects showcase. Day 30 is built here: a
second from every one of the twenty-nine lessons, back to back — thirty for thirty.
All thirty are web-encoded (1280 wide, CRF 28) and live on the CDN. 0 dead URLs.
The "One project, open end to end" section on Examples was four cards linking into
a GitHub folder. A reader on the docs site does not want to leave for a repo tree
to read a BRIEF.md. The section is gone, and so is the examples/docs-reference-project
folder it pointed at.
Also removed the prose references that leaned on it: the Reference Project
paragraph on Go further, the two GitHub-inspect links on Developers, and the
mention on the Studio landing. Each was reworded to talk about "a project"
generally rather than that specific folder.
Examples is now purely the nineteen finished films plus Start from a template.
The changelog entry recording #2977 is left as history. 0 broken links.
The user is supplying the real thirty daily films. Until then this page must not
substitute launch or docs-demo clips for them — that is exactly the reuse the
whole video pass was meant to end.
Removed every borrowed clip: the four hero Frames that pulled the launch film and
the Day 3/4/23 demo cuts, and the docs thumbnails standing in for day posters.
What stays is honest and complete-looking: the thirty day cards, each linking its
real X post, each with a named slot — thirty-days-day-NN.mp4 — so the real files
drop straight in.
**Examples was four films borrowed from other pages. It is nineteen now**, all
freshly rendered, grouped by capability rather than dumped in one wall: product &
launch films, the tooling shown working, motion/sound/effects, and workflows &
integrations. Every one is 100% HyperFrames; where the source project is public
the card links to it. This is the page a reader lands on to see the spread of
what the framework can do, so it should show the spread.
**30 Days of HyperFrames was its own top-level nav group for a single page.**
Moved into Explore alongside Examples, Product updates, Weekly updates and the
Changelog, where a once-a-day series belongs.
**Nine guide videos rebuilt in the light house style.** They were dark — a reader
clicking between sibling guides watched the theme flip. mcp, performance,
rendering, skills, html-in-canvas and remove-background were restyled to the bone
field; faceless-explainer, product-launch and voice-and-audio were re-narrated
with River, the house voice the user-journey films use. Measured luma is 177-230,
matching the journey films, and every one carries audio.
The restyle also surfaced a real bug in the frame itself: the house coral
#D96A4F is 2.95:1 on bone, under the WCAG large-text floor, and `hyperframes
check` failed on it. Darkened to #B44E33 (4.43:1) before any of these rendered.
**canary-rollouts trimmed from 2,443 words to 520.** The statistical-calibration
essay — pre-registered experiment design, a probability formula, rejected
hardware-fingerprinting alternatives — was never documentation for someone
shipping a staged rollout. Cut to Add one / Override one / Remove one / Where it
lives, the part that was already good.
**thirty-days now shows the films.** It was 431 words and 30 links to x.com for a
series entirely about videos, with no thumbnails. It now leads with real preview
clips where a film exists and marks the rest with a visual placeholder rather
than a dead off-site link.