Commit Graph
113 Commits
Author SHA1 Message Date
Miguel Ángel 3b53bfd2f7 feat(registry): the video-primitive moves, documented and customisable (#3090)
* feat(registry): add the video-primitive moves, and rebuild the catalog around them

Adds the motion primitives: 277 new components and the blocks that go with
them, plus the ui-primitives, themes and generators they are produced by. The
registry index goes from 176 items to 454, and the search catalog is rebuilt so
the set that is ranked is the set that can be installed.

Additive on purpose. An earlier pass of this port used rsync --delete, which
removed 101 files that exist on main because the incoming set is not a superset
of the current one: beat-freeze-cut and camcorder-hud among them. Whether the
re-port replaces those or sits alongside them is a product decision and not one
a sync flag should make, so nothing is removed here. If any of them are meant
to go, that belongs in its own commit where it can be seen.

The generator is ported too. Main's version only scans examples, so running it
without this change silently rewrote the index down to nine items. It also
rewrites example manifests from templates.json and will overwrite hand-edits;
those were reverted here after each run, and the diff is worth reading rather
than trusting.

Not covered. The 445 moves are not individually reviewed in this commit; the
machinery that ranks and installs them landed separately so it could be read on
its own. The internal evaluation corpus is deliberately absent: it is 1,400
files of briefs, gold labels and verdicts, and this repository is public.

* docs(catalog): publish the primitive and component pages

Adds the Mintlify pages for the moves this PR ships: 163 component pages, 13
primitive pages, and the navigation that lists them. Without these the moves
land installable and undocumented, which is the worse half of a catalog.

Three things left out deliberately.

The 78 MB of docs/public. Nothing references it: every page loads its preview
from static.heygen.ai, so those bytes would be weight in a public repo with no
reader. Checked rather than assumed, by grepping the pages for the path.

Pages for the thirteen moves that were specified and never built. They had
documentation but no registry item, so a reader would have followed a page to a
`hyperframes add` that fails. Their nav entries are pruned with them, and every
one of the 309 remaining catalog and primitive nav entries was verified to point
at a page that exists.

Spike and scratch files that sit alongside the real docs on the source branch:
qa-gallery.html, experiment pages, bundled player javascript. They are working
artifacts, not documentation.

Not covered: the pages are generated output and have not been read individually.
The nav is verified to resolve, and the previews load from a CDN this commit
does not control, so a broken image would show up in review rather than here.

* docs: list the primitive and component pages in the site navigation

The pages this PR adds were unreachable: nothing in docs.json pointed at them.
This appends a Motion primitives group to the existing Catalog tab and a
Primitives tab, both built from main's navigation rather than replacing it.

Copying the source branch's docs.json wholesale was the first attempt and was
wrong. That file describes a different site, tabs Documentation / Catalog /
Primitives / Packages / SDK / Reference against main's Guides / Studio /
Catalog / Developers, and it references pages only that branch has, so the
preview server reported six dead links.

Verified by running the preview and resolving every entry: 484 page refs, 0
dead, no warnings. Group-relative refs are why a flat existence check is the
wrong validator here: cursor resolves through catalog/components and mcp
through guides, so checking docs/<ref>.mdx flat pruned 22 entries that were
fine.

* fix(registry): restore what the port took from main's components

Two regressions this branch introduced into items main already ships. Both were
found by the repo's own gates in packages/cli, not by reading the diff, and
neither is visible to the no-deletions check: no file was deleted, the contents
of files were changed.

The four liquid-glass blocks stopped installing their library. main lists
lib/liquid-glass.iife.js as a second file on each; the port wrote the older
manifest over main's and dropped that entry. The file is still in git and still
on disk, it simply stopped being something `hyperframes add` writes, so the
installed composition's <script src="lib/liquid-glass.iife.js"> would have
resolved to nothing. Every one of the 294 registry-item.json files this branch
touches was then audited against main: these four lost a file entry, and no
item lost a top-level key.

Fourteen caption components gained an empty <video>. The port added
`<video id="wp-video" ...></video>` — no src, no <source> — to each component
and its demo. It renders nothing and the registry linter rejects it as
media_missing_src. Removed rather than given a placeholder, because main's
version of each of these composes over whatever the host composition provides,
so the element only ever added a broken node; a made-up src would ship a
reference to footage that does not exist.

The removal is deliberately surgical. Four of the fourteen also carry a
substantial rewrite from the port, and only the media element and the rule that
styled it are touched, so a blunt revert cannot take the rewrite with it.

Verified: 2540 CLI tests pass, `bun run lint` exits 0. Before this, three tests
failed.

* docs(catalog): play the real composition, and show what can be changed

Four changes to generate-catalog-pages.ts, so all 445 pages stay consistent
rather than 445 files being edited by hand.

The preview plays the composition instead of pointing at a video. Every new
page pointed at static.heygen.ai/<name>.mp4 and every one of those answered
403, so the reader got a black box where the whole point of the page is to
show them the thing. The objects were never uploaded and rendering 445 of them
would have to happen again on every change. The player is already the thing
being documented, so the page embeds it: the item's directory is copied under
docs/public and an iframe loads it through a small wrapper. 444 of 445 pages
play; the remaining one is a texture item that uses its own preview panel.

The iframe is not decoration. Compositions set styles on `body`, so dropping
the element straight into the MDX would put a composition's global CSS in the
same document as the documentation around it.

Three things this got wrong first, all found by opening the page rather than
reading the output:

  - The wrapper loaded itself. `../<dir>/<name>.html` from inside preview/<dir>/
    resolves back into preview/<dir>/. The player embedded the player and the
    frame went black with a second set of controls shrinking into the corner.
  - Copying only demo.html was not enough. Most demos are a mount shell whose
    child carries data-composition-src="./<name>.html", so the sibling has to
    come with it. Every URL answered 200 and the frame was still empty.
  - `autoplay` and `loop` are not player attributes. Writing them did nothing
    and every preview sat paused on frame 0 — which is blank for any
    composition that animates in. The wrapper drives play() and loops on
    `ended` instead.

The Variables table. generateParams reads `params`; every item ported from the
video-primitives work declares `variables`, a richer schema with a type, a
default and a range. 112 items carry one and not a single page showed it, so
the most useful thing on the page was the one thing missing.

Nav groups. `if (entry.type === "component") return "Effects"` was the
catch-all, so Effects held 267 of 445 pages: an alphabetical wall. Rules keyed
on tags that already exist in the manifests split it; the largest group is now
73.

An install command with a visible copy button. A plain code fence renders one
on hover only, and it was absent from the accessibility tree entirely. This is
the one line every reader comes to take. navigator.clipboard is unavailable on
insecure origins, which is exactly the local preview these pages are written
against, so the fallback path is load-bearing and is what was exercised in
testing.

Verified: 888 preview URLs fetched, 0 failures. Regenerating three times in a
row produces no change, after a first attempt where "Variables" was added to
GENERATED_HEADINGS with a capital V — the set is compared lowercased, so each
run carried the previous section forward and appended a new one.

Not covered: the 445 pages were not read individually. Coverage here is that
every preview resolves and that a page from each of the block and component
paths was opened and watched.

* docs(catalog): put the code on the page

A reviewer with no stake in the work compared these pages against shadcn/ui's
component pages and motion.dev, and returned one gap: the pages carry almost no
code, so they are pointers to a file the reader does not have yet. Its sharpest
example was the Variables table — names, defaults and accepted values, headed
"set the ones you want to change on the element", on a page that never shows an
element or the syntax for setting anything on one.

Two additions, in the generator so all 445 pages get them.

A snippet under the Variables table: the real mount element with
data-variable-values filled in from the item's own defaults, so it is
copy-and-run correct before it is edited. That is the syntax the demos actually
use, not an illustration written for the page.

The item's source, in a collapsed Accordion. These files run 99 to 463 lines,
so inlining them raw would bury everything else; collapsed, the code is on the
page and one click away. Accordion is already what these docs use for this.

A second reviewer, fresh, confirmed the change landed: it called the table and
snippet actionable rather than filler and said the collapsed source earns its
place.

Also here: the preview retries play() until the clock moves. `ready` can flip
before the runtime the player injects for a mounted sub-composition has finished
wiring up, and a play() landing in that window silently does nothing.

Both reviewers additionally reported every preview frozen at 0:00 and called it
fatal. It is not. The player's clock runs on requestAnimationFrame
(direct-timeline-clock.ts), browsers suspend rAF in a hidden tab, and the
reviewing tab was hidden: document.visibilityState read "hidden" while the
player reported ready and not paused, and a one-second rAF loop never completed
a single tick. Seeking the same composition by hand renders it correctly at any
offset. So the retry stops after ~15s instead of spinning forever, and the
comment says why an automated check of a background tab will always read 0.

Not covered: the reviewers' other standing finding, that only some items carry
variables at all, so the pages do not have one shape. 125 of the 206 items
tagged as a primitive declare none, and giving them variables means authoring
them into each composition, not editing metadata.

Verified: lint exits 0, the no-deletions gate passes, nav resolves 598 refs with
0 dead, and regenerating three times running changes nothing.

* feat(registry): give 55 primitives variables that actually do something

The catalog pages listed variables for 112 of 454 items and nothing for the
rest, so most pages could show a reader what a piece looks like but not what
they could change about it. This adds them to 55 more, taking the count to 167.

These are not metadata. A variable is only real if the composition reads it, so
each one is declared on the root, validated in the composition's own script, and
wired to something visible: travel distance, blur radius, direction, density,
accent family, tone, label text. Declaring a knob the code ignores would put a
table in the published docs that lies about the piece, which is worse than
having no table.

Every one falls back to its declared default when the incoming value is missing
or unrecognised, so a bad override degrades to the shipped look rather than to a
broken frame. With no overrides at all, each item renders exactly as it did
before: that was checked per item against `git show HEAD:` in a real browser,
comparing computed styles rather than eyeballing.

Four things this ran into that are worth writing down.

An apostrophe anywhere in a description terminates the single-quoted
data-composition-variables attribute and breaks the HTML parse. Every
declaration in the registry now parses; that is checked, not assumed.

Where a timeline drives GSAP's own y/scale/filter, GSAP writes inline styles
that beat any CSS custom property, so those knobs cannot be won from the
composition. Most of these items keep their motion in a user-owned "Timeline
integration" comment rather than in code, so no timing variables were declared
for them at all. A direction knob on a wipe can still be wired honestly, by
remapping clip-path inset sides through multipliers whose defaults reproduce the
original exactly.

Colour tokens that only reach a :focus-visible outline, or an element sitting at
opacity 0 at rest, render identically in a video. Those were skipped rather than
shipped as knobs that appear to do nothing.

CSS shorthand defaults need care: `border: var(--x, 0 solid transparent)` moves
computed border-color off currentColor even at zero width. Defaults were chosen
to reproduce the original computed style, not merely to look equivalent.

Not covered: 72 primitives still have no variables, and the UI-primitive demos
that scripts/sync-ui-primitives.ts mirrors are now stale for the converted
items. Nothing runs that script in CI today.

Verified: every declaration parses and deep-equals its manifest array, every
declared id is read by the composition, no demo.html changed, and lint exits 0.

* feat(registry): variables for 16 more primitives, and stop the snippet clipping

Takes the count from 167 to 183 of 454. Same contract as the last batch: each
variable is declared on the root, validated in the composition's own script,
and wired to something visible, because a knob the code ignores would put a
table in the published docs that lies about the piece.

The snippet under each Variables table was clipping. Its data-variable-values
payload is one long line and the code block cut it off mid-value, with no wrap
and no scrollbar, so the one line on the page that exists to be copied could not
be read. The fence now carries `wrap`. Worth noting how that survived: the
generated markdown was correct and every mechanical check passed. It only failed
in a browser, which is where it was eventually seen.

Two techniques this round that are worth keeping.

Where an accent has a themed token family, the knob sets a new custom property
consumed by that one surface, with a fallback to the existing token, rather than
overriding the shared accent. Default therefore sets nothing, so an externally
themed accent is not clobbered, and the non-default options still follow the
theme in both light and dark.

Where GSAP owns the property outright and no CSS multiply can win — number-wheel
animates `y` inline — the knob is wired at build time instead: extra revolutions
lengthen the digit strip and move the target, so travel changes while the resting
frame stays identical. That is a real answer rather than a skipped knob.

Motion knobs that multiply a timeline-driven custom property collapse to identity
at rest, so every one of them was verified with that property pinned to a
mid-flight value rather than at t=0, where all options look the same by
construction.

Not covered: 56 primitives still have no variables.

Verified: every declaration parses and deep-equals its manifest array, every
declared id is read by the composition, no demo.html changed, lint exits 0, and
the wrap fix was confirmed on the rendered page rather than in the markdown.

* feat(registry): variables for 21 more primitives

Takes the count from 183 to 204 of 454. Same contract: declared on the root,
validated in the composition script, wired to something visible, defaults
reproducing the pre-edit render exactly.

Three kinds of knob were turned down this round rather than faked, and the
reasons are worth keeping.

A knob that contradicts its own motion. The sheet panel could be moved to the
left, but the recipe drives GSAP x from the right, so the panel would slide in
from the wrong side while the control claimed otherwise.

A knob that needs two defaults. A separator length means width horizontally and
height vertically, so one token would be wrong half the time.

An option that is not an option. Two components were given a green accent that
probed byte-identical to their default, because the theme accent already is that
token. A row in the docs table that does nothing is worse than a missing row, so
it was replaced with one that differs.

Accent knobs set a new property with a fallback to the shared token rather than
overriding it, verified by rendering with an external accent in place and
confirming the default still yields to it. Motion knobs multiply a
timeline-driven property so they never fight the inline styles GSAP writes;
because those collapse to identity at rest, each was checked twice, once at rest
against HEAD and once with the driven property pinned mid-flight.

Verified: every declaration parses and deep-equals its manifest array, every
declared id is read, defaults match HEAD on computed styles and on a pixel hash
of the rendered element, no demo.html changed, lint exits 0, and the
no-deletions gate passes.

* feat(registry): variables for 4 more primitives, and make manifests agree with their compositions

Takes the count to 207 of 454.

Four items carried a different description for their exit variable in
registry-item.json than in their own data-composition-variables. The catalog page
renders the manifest, so the published table described the knob one way while the
composition header described it another. The composition wins: it is the file
that implements the variable and the declaration is what the runtime reads.

The skill docs no longer describe a hosted tier, since the CLI now ships the two
local tiers only, and skills-manifest.json is regenerated to match.

* feat(registry): variables for 4 more primitives

Takes the count to 211 of 454.

Two knobs are worth calling out because they touch things the timeline also
touches. skeleton-block slide multiplies the driven row offset, so it is
identity at rest and only bends the middle of the move. slider value sets the
resting fill together with the readout text, aria-valuenow and aria-valuetext,
so all three agree; a composition that tweens the fill takes over from there and
owns the readout, which the comment header states plainly rather than hiding. A
multiplier was rejected there because a 0 to 1 tween would push the fill past
the end of the track.

Knobs on elements that sit at opacity 0 at rest were kept only where the shipped
recipe reveals them, and verified with the reveal forced on as well as at rest.
Skipped: an aria-label string knob that never renders, and an accent token
declared in one item CSS that nothing consumes.

* refactor(registry): drop the UI primitives, this is a video catalog

Removes 66 items tagged ui-primitive: accordions, buttons, inputs, dialogs, a
calendar. They are a shadcn-style interface component set that happens to be
expressible as HTML. None of them animate anything, so in a catalog whose job is
to offer moves for video they widen the surface without making it more useful,
and each one is a page a reader has to skip past to reach something that moves.

Every one is new on this branch. None exists on main, so nothing main ships is
being taken away; that was confirmed against origin/main before deleting rather
than assumed, and the no-deletions gate still passes.

Removed with them: registry/ui-primitives, the Operator Black token and contract
files only these items consumed, and the tooling that maintained them
(sync-ui-primitives.ts and scripts/lib/ui-primitives). No other registry item
declares a dependency on any of the 66, so nothing else loses a piece. The now
empty UI Primitives navigation rule goes too.

Generated output is pruned with the sources. The page generator writes files but
never removes ones whose source has gone, so a stale page would have survived and
404d its own preview. Verified: 0 orphan pages, 0 orphan previews, and the
navigation resolves 532 references with none dead.

This does discard variables authored for 54 of them earlier on this branch. That
work is in the history if these ever come back, and it is the right trade: they
should not have been in a video catalog to begin with.

The catalog is now 388 items. Lint exits 0 and 2522 CLI tests pass.

* feat(registry): every motion and transition primitive is now customisable

The last 16 primitives get variables, so none is left without them. 173 of 388
items now declare variables; the rest are blocks and showcases, which are whole
scenes rather than parameterised moves.

Same contract throughout: declared on the root, validated in the composition
script, wired to something visible, and falling back to the declared default on
missing or unrecognised input. With no overrides every item renders exactly as
it did before, verified per item against the pre-change render in a real browser
at rest and at pinned mid-flight states, comparing computed styles and rects and
in most cases a screenshot hash.

This round refused several knobs rather than shipping ones that only look real.

A tilt-card depth knob was written, measured, and thrown away: the card sets
overflow hidden, which forces transform-style flat, so the authored translateZ
is already inert and every option probed identical. It ships a glow knob
instead, which drives inset and visibly changes at rest and under the drift.

slot-machine-roll has no free travel knob because the roll is exactly one row
height and any multiplier lands the reel off-register; size scales row height
and roll distance together, which is the only honest version. soft-blur-in
offers up and down but not left and right, because the shipped tween resets y
and not x, so a horizontal offset would never animate away.

Two pre-existing bugs surfaced while checking honestly, both left alone as out
of scope but worth recording. zoom-through-transition and tracking-in each tween
a custom property that is never set, so CSS reads it as zero and the move starts
from zero rather than from its authored value. The depth and tracking knobs are
scoped around that and their headers say so, rather than pretending the tween is
what it appears to be.

Verified: 388 items, 0 primitives without variables, 0 items where the manifest
and the composition disagree, 0 declared-but-unread variables, nav resolves 532
references with none dead, no demo.html changed, lint exits 0, and the
no-deletions gate passes.

* feat(registry): raise the catalog quality bar, and add eight primitives

Cuts 37 components, adds 8, and writes down the standard both decisions were
made against.

The 37 removals are all new on this branch and absent from main, so nothing
shipped is withdrawn. Each was audited with two pieces of evidence: source
identity after name normalisation, and a composition-level contact sheet
showing the members animate identically.

The largest group was 13 files byte-identical apart from an h3 and one
sentence. Nothing marqueed, panned, zoomed, deployed or dragged. An honesty
tiebreak decided survivors: frosted-glass-wipe has no backdrop-filter,
spring-scale-in has no spring, masked-slide-reveal has no mask,
short-slide-right travels up, and three-particle-ribbon differs from
three-orbiting-cards by one number while having neither particles nor a
ribbon.

Two independent audits agreed 10 out of 10 on a shared calibration sample,
in both directions, including three items a first pass wrongly condemned.

The rubric is the durable part. Fatal criteria are separated from fixable
ones, because no-timeline alone hits 97 items including some of the best;
promoting it would have cut 97 and left a worse catalog. It also records the
harness rules that make a verdict reproducible: render from the composition
rather than the demo, since demos carry content the installed item does not,
and mount sub-compositions rather than inlining them, since inlining renders
black frames indistinguishable from a dead item.

The eight additions target measured gaps. Camera language ranked first
because PSNR across 30 reference demos showed the most impressive
environments barely move: they are sets, not shots.

camera-shake carries nine lens-accurate profiles, amplitude scaled by focal
length so a wide lens shakes differently from a telephoto. rack-focus splats
each light through the aperture shape, so a defocused point becomes an image
of the iris, with flux conserved so highlights survive defocus.
camera-dolly-zoom solves focal length from distance, holding subject size to
0.000 percent drift while the background grows 53 percent. Plus
oscilloscope-trace with history-free phosphor persistence, bar-chart-race,
split-flap-board, spiral-galaxy and vfx-anamorphic-flare.

Each is verified by rendered frames and a seek-equals-playback check rather
than by check passing, which is not a visual gate.

* fix(registry): let the split-flap board finish flipping on screen

The board declared 8s but every flap had settled by 3.5s, so more than half the composition was a still frame and the catalog preview opened on it.

* fix(registry): keep the thread-message-stack payload parseable

A line wrap had put literal newlines inside the JSON string literals of the blocks data-hf-primitive-data payload, so JSON.parse threw in the browser and the composition never ran. The preview script hid it: it rewrote the payload in the temporary copy it captured from, so the catalog picture looked right while every installed copy stayed broken. That repair pass is gone and the payload is fixed where it ships.

Its two tests could not have caught this. Both were written against vitest in a directory the repo runs with node:test, so neither was in test:scripts and neither had ever run. They are converted and registered, along with a new one that JSON.parses every payload in the registry, and that one was checked against the re-wrapped shape before being kept.

* fix(registry): close the apostrophe that truncated a variables declaration

chromatic-aberration-wipe described its accent as "the incoming scene's gradient" inside a single-quoted data-composition-variables attribute, so the attribute ended mid-JSON and the tag never closed. The formatter refused to parse the file, which is how it surfaced, but the runtime would have read a truncated declaration.

Also formats the 159 registry and docs files the branch had left unformatted, regenerates the skills manifest, and drops docs/primitives: those 13 pages import /snippets/PrimitivePlayer.jsx and read docs/public/primitives/, neither of which is on this branch, so mint failed the build on them. Nothing links to them and they ship whole on feat-video-primitives.

CI ran test:scripts before building core, so the preview test added here failed on a missing dist rather than on anything it checks. It now runs after the builds.

* fix(registry): make the review findings real fixes

Ten items declared variables on their composition root but had no variables key in the manifest, so their generated pages shipped no explorer at all. Their manifests now mirror the root. Two more disagreed only in description text, and the root was the truthful side: both compositions paint an inset ring, not the slabs or colour pair the manifest described.

The caption <video> removal left 24 CSS rules addressing elements that no longer exist. Removed, excluding the four ids that were already orphaned on main.

thread-message-stack could not stay fixed: oxfmt reflows a divs contents and lands a newline inside a JSON string literal, so the payload broke again on the next format. A script tag is not an option because the runtime strips every script out of the mounted clone. The reader normalizes HTML whitespace instead, which is what makes it survive any reflow, and the guard test now asserts that contract rather than the byte layout.

downloadFile had lost its 30s timeout, DownloadOptions, and the mid-pipeline error plumbing in a rewrite that was only meant to fix redirects. Five callers were left with no stall guard. Restored, redirect handling kept.

warnUnknownEnumValues re-did the parse readDeclaredDefaults had already done. Both now share one readDeclarations, and the rest splits into compositionLabel, declaredOptions and unknownEnumValue. 1909 core tests unchanged.

Deletes build-qa-gallery, theme-gate and generate-primitive-pages: nothing invokes them, two read a coverage map four directories above the repo root, and the pages the third generates are no longer on this branch. Wires check-no-main-deletions, which is the opposite case, real and tested and never run.

* fix(registry): stop shipping a stale copy of the catalog-search work

This branch carried re-authored copies of the CLI search commits rather than the ones on their own PR, so merging it would have rolled back six later fixes: the vector cache that refuses a half download, the 0o700/0o600 modes, the rebuilt-from-registry index generator, the coverage gate and its CI job, and the scripts typecheck. Those files now come from that branch.

registry.json still listed 64 items whose directories the UI-primitive removal deleted, so hyperframes add would resolve a name and then fail on missing files. Regenerated from disk: 358 searchable items, 358 vectors, gate green.

Also drops an internal provenance block from thread-message-stack, along with the type and the two JSON schemas that existed only to describe it. It published an artifact id, a version id and a heygenverse:// URI, none of which mean anything to someone installing a block, and a public registry is the wrong place for them.

Typechecking scripts/ for the first time surfaced 45 errors in this branch. Fixed rather than suppressed: the geometry test reads positions through one accessor that names a missing index instead of letting NaN reach a tolerance compare, and the null-returning shape helper is asserted at its call sites, except in the test whose subject is the null.

* refactor(scripts): split the page builder into its numbered sections

generateItemMdx had grown to cyclomatic 26 across 196 lines while its own comments already named the seams. previewSection, usageSection and footerSection now own one each, taking it to 13. Regenerating all 358 pages afterwards produces a byte-identical tree, which is the check that matters for a generator.

* fix(cli): repair what the cross-branch file take broke

Taking files wholesale from the catalog-search branch reverted the downloadFile timeout restored one commit earlier, so five callers were back to no stall guard at HEAD. Restored on both branches this time, since that branch never had it either.

It also took that branch test:scripts line without the vitest it depends on, so the script exited 127 and the CI Test job would have failed on a missing binary rather than a test. vitest is a root devDependency now, and the run is scoped to scripts/catalog/ with the slash: without it the prefix also matched catalog-preview-temp.test.ts, a node:test file with no vitest suite in it. Both branches had that one.

Four registry items and their docs copies carried absolute paths from a working directory. A public registry is the wrong place for them and history is permanent, so the sentences now name the source without the path.

Skill docs came from before the code they describe: the catalog command reports unindexed and applies installability after ranking, and both SKILL.md files now say so.

Also drops a double type assertion and ten dead ?? NaN coalesces from the geometry test, the second of which reintroduced exactly the NaN-into-a-tolerance-compare that the checked accessor exists to prevent.

* fix(ci): resolve core from source and take only item directories

The scripts typecheck failed on generate-registry-items importing @hyperframes/core by package name. It resolves on a machine with a warm node_modules, which is why it passed locally, and not in CI. Every other script in the directory already imports core from source and says why in a comment.

The preview job derived its item list with a sed that needs a trailing slash, so registry/components/CATALOG.md never matched, survived as a full path, and was handed to the renderer as an item name. The grep now requires a directory component. Simulated against this PR: 219 items, none of them a path.

* refactor(registry): load gsap from the cdn like every other item

store-badge-lockup vendored gsap 3.14.2 as a 4,200 line minified file and installed it into the users project, while 540 other items load that exact version from jsDelivr. Repointed, the copy deleted and the manifest entry with it, so hyperframes add store-badge-lockup no longer writes a second copy of gsap into someone elses compositions directory. Re-rendered and re-generated: the preview still draws.

* feat(registry): swap in the detailed device models

Replaces the iPhone and MacBook models in the three device blocks. The old assets were untitled meshes with no keyboard on the laptop; these name every part and model the keycaps, speaker grilles, antenna bands and camera plateau.

Not a drop-in. The compositions found the screen by side effects, the material that happened to carry an emissiveMap for the phone and a mesh literally called matte for the laptop, and neither exists now. They select front-glass and display instead.

Both panels ship UVs authored for a tiling material, the laptop runs u -6.3 to 6.3, so one screen image clamped and smeared across the panel. Planar UVs are derived from each panel bounds at load.

The old phone display sat at the model minimum Z and the timeline spins assume a screen facing -Z. These face +Z, so the model is aligned by reading which of its own parts is front rather than re-timing the animation.

Removes the hand-drawn Apple logo from two blocks: the replacement ships apple-logo meshes, and the drawn one used coordinates read off the old lid, so it floated beside the device.

The preview copy only took top level files, so models/, lib/ and assets/ never reached docs/public and 38 items rendered there without their assets. That is the source of the non-blocking 404s in the preview job. It recurses now, which also brings vendored bundles across, so the generated tree is out of the lint scope.

The html-in-canvas notice is a Danger callout: without the flag the preview is a black rectangle, which is a prerequisite rather than a caveat.

* chore(registry): rebase onto the merged catalog search

This branch carried its own copy of the catalog-search work so that merging it in either order could not regress the other. That copy is now the older one: main has the consent fix, the contributor path for someone without the embedding model, the restored download test and the corrected gate message. Every file main owns is taken from main, and the three duplicated CLI commits are dropped rather than replayed.

Regenerated afterwards, because the registry it describes has changed: registry.json, the vector index, the catalog pages and the skills manifest.

* fix(scripts): stop the rebase reverting the preview pipeline

Resolving the rebase in favour of this branch took three files whose newer versions had already merged, so the branch quietly reverted them.

generate-catalog-previews.ts lost encodeForWeb, which exists because publishing masters directly put 25 Mbps files on the docs CDN and one 20-second preview was 60 MB. It also lost the ffmpeg transcode, so a jpeg capture was being written straight to a .png path while the comment above still said it transcoded, and it lost openOpaqueCapture, re-creating the second copy of a capture setup that was extracted precisely to stop there being two. This PR renders previews for over 200 items, so all three shipped at scale.

scripts/tsconfig.json regained exclusions that hole the gate, and generate-template-previews.ts went back to importing the producer by package name, which is the CI failure that import was changed to fix.

All three are taken from main. Also drops an alignScreenToMinusZ copied into the laptop block, which has no front and back to compare and never called it, and makes the preview copy lstat so a symlinked directory cannot send it outside the repo.

* fix(registry): clear the five items this PR added that the linter rejects

The registry linter is not wired into CI, so five items this PR adds were shipping with real render defects nobody would have seen fail.

caption-camera-follow and grade-split-reveal styled their root by its own class. Sub-composition CSS is scoped to [data-composition-id=...] <selector>, so a selector whose leftmost part is the root class becomes a descendant selector and stops matching the root: the scene renders unstyled at render time while looking correct in every static check and in preview. Both now key off the attribute the scoper already adds.

logo-brand-close tweened letterSpacing, which the browser snaps to integer device pixels, so the ease-out tail stutters under seek-by-frame capture. It is a scaleX now.

terminal-simulator named SFMono-Regular, which the renderer cannot resolve, so the text silently fell back.

oversized-cursor was a false positive: the rule scans raw source for head tags and a literal one written inside a JS comment paired with the real closing tag. Confirmed against a render, nothing leaks into frame, so the comment says head element rather than the tag.

Also stops generate-registry-items.ts dropping catalogArtifact.revision. build-local-vectors.ts stamps it so the CLI and the coverage gate can tell whether the published vectors still describe this registry; regenerating the item list erased it, and the gate then failed until someone rebuilt the index.

41 items still fail the linter, every one of them pre-existing on main.
2026-08-10 14:26:55 -04:00
Miguel Ángel 68205dbbc1 feat(cli): search the catalog by meaning, on this machine (#3089)
* feat(cli): search the catalog by meaning, in three named tiers

Browsing the registry means matching names and tags, which fails whenever the
author's wording differs from yours. "make the pace feel faster" finds nothing
when the move is described as "velocity-driven blur". This ranks by meaning
instead.

Three tiers, and the command always says which one answered:

  words       shared vocabulary, free, offline, no account
  on-device   bge-small, free, offline, one opt-in download
  hosted      Gemini, free for signed-in HeyGen users

The tier is stated because a quietly worse answer looks exactly like a good
one. --json carries it as a token alongside dropped, shown, total and
top_score, so an agent reads provenance as data rather than matching English
that is written to be reworded.

Two consents, asked once each, and never conflated. Sending a query is a
privacy question, so the prompt says the query is sent. Downloading a model is
a disk and bandwidth question, so that prompt talks about size. Neither fires
without a terminal: an unattended run sends nothing and downloads nothing
unless a flag records that a person agreed.

The catalog is derived from registry-item.json rather than from a separate
document, so the set that is ranked and the set that can be installed are the
same object by construction. Only the on-device vectors are committed; the
hosted vectors are nine megabytes and belong on the server.

top_score is reported and never acted on. A "nothing matched" threshold looked
clean on long briefs and collapsed on the short queries people type: "a logo
appears" scores 0.6181 and keyboard mash scores 0.6417, so any cut that catches
the noise rejects the real query. The measurement is in the evals directory
rather than in this branch.

Not covered here. The published recall figures were measured against a separate
hand-written document, not against registry text, so they should not be quoted
for this catalog until re-measured. The offline tier needs a normal install: a
single-file build cannot load the native ONNX runtime, which the command now
reports instead of silently degrading. And the drop-detection path has never
been observed firing outside its author's tests.

* fix(cli): make this branch pass the repo's own gates

Three things `bun run lint` and `fallow audit --base origin/main` rejected.
CI runs both, so none of this branch would have gone green. Found by running
them, not by reading the diff.

process.exit in catalog.ts, twice: an invalid --type and a cancelled picker.
check:cli-process-ownership reserves that for cli.ts, and the rule is not
cosmetic — process.exit tears the process down where it stands, so anything
cli.ts has queued to run on the way out is dropped. finishCommand throws a
CliResultSignal that cli.ts turns into the exit code, which is what init.ts
already does for a cancelled prompt.

Three exports with no consumers. normalize keeps its body and loses its export;
localEmbedder is the only caller. modelsDirectory goes entirely, having no
caller inside its file or out. The WordPieceConfig re-export goes, and with it
the import it existed to forward: the type is exported from wordpiece.ts, where
its consumers already take it from.

Complexity. prepareOnDeviceTier is lifted out of run(), which took run from 64
cyclomatic and CRAP 948 to 54 and 684. That block is one decision — can the
offline tier run, and if not, why not — and its only product is a list of
warnings, so it reads and tests as a unit, which it could not do inline.

The rest is suppressed rather than refactored, each with its reason on the line
above. Finishing run() means extracting its three output paths, and that is a
refactor of a command this branch already changes for other reasons: a separate
initiative, not something to absorb here. Every suppression says what shape the
function has and why; a bare marker on a function nobody can justify is how a
threshold stops meaning anything.

Verified: `bun run lint` exits 0, fallow reports no issues across 27 changed
files, and 2540 CLI tests pass.

* feat(cli): ship the local search tiers only, drop the hosted one

Search now has two tiers, both local: shared-vocabulary word matching, and the
opt-in on-device model. The hosted tier, which sent the query to a HeyGen
endpoint and ranked it with a hosted model, is removed.

This is a scope decision, not a defect. The endpoint works and its own change is
reviewed and green; it is simply not what we want to ship first. Landing local
only means the feature has no backend dependency, no auth requirement, and
nothing leaves the machine unless someone opts into downloading a model.

Gone: registry/smartSearch.ts and its test, the --smart and --no-smart flags,
the outcome plumbing through the command, the remote branch of applySearch, the
remote tier, and the hosted-only JSON fields (ranking, catalog_version,
top_score). Also the smartSearchEnabled consent field in telemetry config, which
was the persisted storage behind the hosted consent and would otherwise have
been left as dead configuration surface.

Kept exactly as they were: both local tiers, the --on-device and --yes flags,
the download consent prompt, and the runtime check that happens before the
download rather than after it. The --json envelope still reports query, tier,
tier_detail, shown, total, dropped, warnings and results, so an agent can still
tell which tier answered and why. tierToken now distinguishes on-device from
words.

Verified: lint exits 0, fallow reports no issues, 2522 CLI tests pass, and the
command was exercised directly. A query answers on the on-device tier where the
model is installed and falls back to word matching where it is not, reporting
that fallback in warnings rather than silently. An unknown --type still exits 1
with a readable message, and --smart is now rejected as an unknown flag.

* fix(cli): count only moves this registry cannot install as dropped

The dropped count was computed against the list left after the user's own
--type and --tag filters, so every move the user excluded was reported as one
the registry is missing. Filtering made the number go up: the same query
reported 277 unfiltered and 302 with --type block.

The count exists so a caller can tell "nothing matched your words" apart from
"the ranker suggested things this project cannot install". Conflating it with
user filtering destroys exactly that signal, and worse, genuine index skew and a
self-inflicted filter printed a byte-identical line with opposite remedies --
one means refresh the shelf, the other means drop a flag, and refreshing does
nothing.

Now counted against the registry rather than the filtered view. The manifest is
already fetched whole and narrowed in memory, so keeping the unnarrowed name set
costs no extra request, and item loading still runs only on the filtered subset.

Verified against ground truth rather than by eye: the vector artifact holds 411
names, the registry holds 168 installable items, and 134 of those names exist in
both, so 277 are genuinely uninstallable. The count now reads 277 unfiltered,
277 under --type block, 277 under --type component and 277 under --tag, and the
skew it reports is real -- the artifact predates dropping the UI primitives and
still ranks moves that are no longer on the shelf.

Reported by Vance Ingalls, who also noted this closes an item the status doc
listed as unverified. Two earlier sweeps could not make the count fire because
neither combined a filter with a query.

Tests pin the three cases: a genuinely absent name counts, a filter-excluded
name does not, and a fully installable ranking reports zero.

* fix(cli): tell the user when meaning search cannot see the catalog

The on-device index was fetched once and never revalidated: the only
freshness check was two existsSync calls. A move added after that fetch was
invisible to meaning search permanently, not down-ranked but absent from the
candidate set. The registry manifest on the same command carries a 24h TTL,
so the two halves of one feature disagreed about staleness.

The dropped count reported over-coverage only, names the index has that the
registry lacks. Under-coverage was never computed, so the harmless direction
was instrumented and the costly one was silent. Reproduced with an index
truncated to 120 of 168 moves: dropped read 0, perfect health, while 48
moves were unreachable.

Counts under-coverage from the name list the artifact already carries, so no
extra request. Warns only when non-zero, and names the remedy.

The remedy had to be made true: --on-device could not refresh a stale index
because hasLocalVectors short-circuited the fetch. That flag now refetches
when the index is absent or no longer covering.

Two defects the reproduction surfaced. A failed refresh reported the tier
unavailable while the old vectors were still on disk and still ranking. And
the fetch wrote its two files one at a time, so failing between them paired
a new name list with an old matrix, a hard load error rather than stale
data. It now writes both or neither, which matters more once refresh runs on
staleness.

top_score returns, scoped to the on-device tier and set to the score of the
best result actually shown rather than the ranking head, which can describe
a row the caller never received.

Also: scripts/ is now typechecked. It never was, which is how a build script
that crashes after the paid embedding call, and two scripts whose imports do
not resolve at all, went unnoticed. 43 errors fixed, no suppressions.

And the docs stop describing a --smart hosted tier that was deleted, an
item that does not exist, and a registry refresh that cannot fix a stale
vector index.

* ci: fail when the search index stops covering the registry

The catalog vector artifact is regenerated by hand. Nothing in CI, in
package.json or in a hook rebuilds it, because embedding needs the 32 MB
model. So adding a registry item silently makes it invisible to meaning
search until someone remembers to regenerate.

The failure is asymmetric, which is what makes it easy to miss. Removing an
item is self-healing: the ranker still scores the dead vector, then filters
the name before display, so a user is never offered something they cannot
install. Adding one is not: the item is absent from the candidate set
entirely, not ranked low.

Comparing the two name lists needs neither the model nor a network call, so
the gate runs in seconds. CI checks rather than fixes, for the same reason
it cannot regenerate.

Scoped to blocks and components. Examples are starter projects a user
scaffolds, never something catalog ranks, and the artifact carries no vector
for them, so demanding one would keep this gate permanently red and it would
be ignored within a week.

Verified in both directions rather than assumed: adding an unindexed item
exits 1 and names it, restoring the registry exits 0.

* fix(catalog): rebuild the search index from the registry

build-local-vectors.ts read registry/catalog-artifact/catalog.json, a file no script in this repo writes and which is not committed, so the documented regeneration command failed on a missing path. That is why the index could drift from the registry with nothing to run to fix it.

It now reads registry/blocks/* and registry/components/* through catalogFromRegistry, the existing helper that already produced the right shape but had no caller. Rebuilding reproduces the shipped 168 rows byte for byte.

A lefthook catalog-index command regenerates and re-stages both artifact files whenever a staged registry-item.json changes, mirroring the skills-manifest pattern, so adding or removing an item keeps the index in sync without anyone remembering to. Verified end to end: staging a new item took the artifact 168 to 169 rows and staged it in 0.80s.

* fix(cli): refuse a half-downloaded vector cache

The two artifact files have to agree on how many rows there are, and until now nothing checked that before writing them. A truncated or wrong-model response landed in the cache and only failed at load, on every later search, until someone cleared it by hand. The pair is now checked first and refused as a unit, and the cache is created 0o700 with 0o600 files rather than inheriting the umask of a directory the caller may have pointed anywhere.

Also lifts the capture setup the two preview generators had drifted into sharing into scripts/preview-capture.ts, and splits the vector builders batching and packing out of main. Both were findings the audit attributed to this branch.

* fix(cli): keep the catalog vitest run with the tests it runs

Restacking took the base package.json wholesale, which dropped the vitest dependency and the scripts/catalog run this PR adds. Both belong here rather than under it.

* fix(cli): stop the declined model download from happening anyway

Answering no to the on-device download offer recorded no and warned, then carried on. The guard below it is localModelConsent() !== false, which the decline had just made false, so it was skipped rather than taken: control reached recordLocalModelConsent(true), overwrote the answer with yes, and fetched the 32 MB model the user had refused. Next run it never asked again.

No test could catch it. The stub pinned localModelStatus to ready, so the prompt never fired, and recordLocalModelConsent was a no-op that recorded nothing.

Two tests now cover the offer, and they need three things the old stubs did not model: the run has to look like a terminal, because off one the command treats --on-device as the consent and never asks; the ONNX probe has to answer true, or an accepted offer returns at the runtime guard before it can download; and the status has to follow the recorded answer, or the second offer later in the run fires as well. Removing the return makes the decline test fail.

* fix(catalog): let someone without the model still add a component

The pre-commit hook rebuilds the search index, and rebuilding needs the 32 MB embedding model. An outside contributor adding a registry item does not have it, so their commit died inside the ONNX loader on an ENOENT naming a path they never set, and the CI gate then told them to run the command that had just crashed.

The model is an opt-in for search, not a build dependency, so nobody is charged for it to contribute. The builder checks first and explains itself, exiting 3 for cannot as distinct from 1 for failed. The hook treats 3 as skip and lets the commit through. The gate now names both paths: regenerate if you have the model, leave it if you do not and a maintainer will.

Verified both ways: with no model the builder explains and the hook exits 0; with the model it still regenerates byte-identically.

* docs: say that anyone can add a registry item, and stop hand-editing a generated file

Two defects, one of them the reason 64 stale entries survived in registry.json.

The checklist told contributors to add their item to registry/registry.json. That file is generated from the item directories, so an entry added by hand survives until the next regeneration and then vanishes, and one left behind for a directory that no longer exists is worse: hyperframes add resolves the name and then fails on missing files. Both CONTRIBUTING.md and the agent-facing skill reference now run the generator instead.

Nothing said contribution was maintainer-only, but nothing said it was not either, and two steps do need assets an outside contributor has no reason to install. Those are now named in a table with what happens if you do not have them, matching how the preview image was already handled. The search index is the new one: the model behind it is a 32 MB opt-in for search, not a build dependency.

* fix(cli): harden on-device catalog search

* fix(cli): refresh stale catalog vectors

* test: create catalog vector temp dirs securely
2026-08-09 22:59:15 -07:00
Miguel Ángel f28bc80a1d feat(scripts): fail a branch that deletes files main still ships (#3150)
* feat(scripts): fail a branch that deletes files main still ships

Written after a scare that turned out to be a measurement error, and the error is the reason it exists. Comparing tip to tip on a branch a month behind reports every file main has added since the merge base as a deletion: 1,284 of them, an entire skills tree among them, none of it real. A merge keeps mains side and a pull request shows the three-dot diff, which reported zero.

So the gate uses the three-dot form and reports renames separately, because in a name-only diff a rename is indistinguishable from a deletion and treating them alike would either mask real loss or block every legitimate move.

* ci: enforce the no-deletions guard
2026-08-09 22:24:42 -07:00
Miguel Ángel 79dff20516 feat(scripts): typecheck the scripts directory (#3149)
* feat(scripts): typecheck the scripts directory

scripts/ was the one TypeScript surface nothing typechecked. Adding a project for it surfaced real errors rather than style: a preview generator passing string | undefined where a string was required, a readdir result indexed without a bound, and two non-null assertions standing in for a filter that could have narrowed the type instead.

The two preview generators had also drifted into sharing a capture setup, down to the comment explaining why the capture is opaque. That lifts into scripts/preview-capture.ts, so the reason is written once and both callers own the handles they have to close.

@hyperframes/core and @hyperframes/producer become dev dependencies because the scripts import them; without that the project resolves on a machine with a warm node_modules and fails in CI.

* fix(scripts): use source imports consistently
2026-08-09 22:07:57 -07:00
Miguel Ángel 0bda6b55b8 feat(cli): track which registry items add installs (#3099)
* 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.
2026-08-07 16:00:23 -07:00
Miguel Ángel 218eff7d36 fix(scripts): render template-only blocks in catalog previews (#3098)
* fix(scripts): render template-only blocks in catalog previews

The catalog preview renderer treated any file containing `__timelines` as a
standalone composition and rendered it as index.html directly. The 12 VS Code
snippet blocks register their timeline inside a `<template>`, which stays
inert until a host mounts it, so every one of them failed with "Composition
has zero duration" and no preview could be produced from the registry at all.
Six of the previews on the docs CDN were hand-made from a project still
mounting Monokai, so Dark+, High Contrast, High Contrast Light, Solarized
Light, Visual Studio Dark and Visual Studio Light all showed Monokai's video.

Detect standalone-ness on the document with template content stripped, mount
the mirrored install-layout copy so a block's own `../assets/*` references
resolve, and capture posters opaque: `format: "png"` is the engine's
transparent mode and forces `background-image: none` on every composition
root, which erased the desktop backdrop these blocks paint.

Publishing gets the missing half too: preview URLs are stable and the objects
are uploaded `immutable` with a one-year max-age, so a re-upload alone never
reaches a reader.

* fix(scripts): install ffmpeg in the preview job and fix the sibling renderer

The canary this PR added caught its own regression: the poster transcode
shells out to ffmpeg, which ubuntu-latest does not ship and this job never
needed, so both canaries failed with `spawnSync ffmpeg ENOENT`. Install it
the way every other render job does. `encodeForWeb` has always shelled out to
the same binary; the job only got away with it because `--skip-video` skipped
that path.

generate-template-previews.ts captures posters through the same transparent
`format: "png"` mode, so any template painting its own backdrop loses it
exactly as the code snippets did. Fixing one renderer and leaving its sibling
on the broken call would just move the bug.

Also fold the three separate parses of registry-item.json into one read: they
had drifted into three different failure behaviours for the same file.
2026-08-07 15:17:30 -07:00
ukimsanov aa928f399a style(scripts): oxfmt the nav-suppression assertion
The long assert.match line tripped oxfmt --check in CI (Preflight/Format red,
which cascaded regression/player-perf/preview-regression into skip-then-fail). The
pre-commit format hook doesn't cover scripts/*.mjs, so it slipped through. Wrapped
per the formatter. Flagged by Magi and Rames.
2026-08-05 11:47:59 -07:00
ukimsanov fed2baf278 docs: correct composition offset rationale; pin nav fix; label-in-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.
2026-08-05 11:32:21 -07:00
ukimsanov f3d1d531d1 docs(examples): reset control state on offscreen release; precise a11y labels
- 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).
2026-08-05 10:59:29 -07:00
ukimsanov 630c8900f5 docs(examples): reduced-motion play starts both films; precise media-start layers
- 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.
2026-08-05 10:41:34 -07:00
ukimsanov d373c3f4a0 fix(catalog): explicit section ownership, no body-sniffing heuristic
carriedSectionsFrom() decided whether a ## Usage section was generated by matching
its first line against a list of historical opener phrases — so a hand-written
Usage section that happened to open that way was classified as generated and
silently deleted on regeneration. Ownership is now purely set membership: a
section is generated iff its heading is one the template emits, and ambiguous
'usage' is no longer in that set (the template never emits it), so any ## Usage is
carried. Exported carriedSectionsFrom behind an entrypoint guard and added two
executable preservation fixtures. Flagged by Magi (#5).
2026-08-05 10:23:37 -07:00
ukimsanov aa49f7d924 Merge remote-tracking branch 'origin/main' into docs/pages-show-not-tell 2026-08-05 05:56:59 -07:00
ukimsanov 8cadefa5d8 fix(scripts): import the changelog style note instead of duplicating it
changelog-weekly.ts hard-copied the CHANGELOG_STYLE_NOTE literal that set-version.ts
exports; both feed the release gate, so a drift silently weakened it. Import the
constant so the two markers cannot diverge. Flagged by Copilot and Rames.
2026-08-05 05:51:16 -07:00
ukimsanov 6ff6601dd0 fix(catalog): restore the required Related topics section on generated pages
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.
2026-08-05 05:38:58 -07:00
ukimsanov ddbd547fad fix(scripts): restore the texture instruction phrase a core test pins
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.
2026-08-04 22:33:43 -07:00
ukimsanov b33974cf72 chore: quiet fallow complexity on the catalog generator's grown functions
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.
2026-08-04 17:43:49 -07:00
ukimsanov 618f73c266 Merge remote-tracking branch 'origin/main' into docs/pages-show-not-tell
# Conflicts:
#	skills-manifest.json
2026-08-04 16:16:58 -07:00
Miguel Ángel ebdd1893c4 fix(studio): reconcile external edits before reload (#2993)
* fix(studio): reconcile external edits before reload

* fix(ci): retry transient workspace installs

Make external reload retry behavior honest and isolate reload listeners.

Remove the dead SDK timestamp parameter.
2026-08-04 23:13:19 +00:00
ukimsanov 74ba97b7ed fix(scripts): the repo already knew which items have no poster
Thirteen manifests declare `preview: { video }` with no `poster` key, and that
omission is deliberate — no .png was ever produced for them. main reads it in one
line: `if (manifest.preview) return manifest.preview.poster`.

I deleted that line as "config nobody varies", having checked whether any poster
DIFFERED from the generated URL (none did, 100 of 100) and never having checked
whether any was ABSENT (thirteen were). Then I replaced it with a filesystem
check against docs/images, which is gitignored, so it was false everywhere and
would have stripped all 168. Then I removed the poster entirely. Three wrong
answers to a question the repo had already answered.

Restored, and the page's video tag now asks the same function rather than a
second source of truth. 155 of 168 carry a poster, matching main exactly: 154
video pages plus texture-mask-text, which renders mask images instead of a video.

The 13 remaining dead URLs are in catalog-index.json only, which no code in
docs/ or packages/ reads, and no rendered page requests them.

The complexity cuts to the encode pass stand: hasAudio deleted (ffmpeg ignores
-c:a with no audio stream), and the spawn+Promise wrapper is execFileSync.
2026-08-04 12:59:50 -07:00
ukimsanov 592301248e fix(scripts): drop the catalog poster instead of guarding it, and cut the encode pass down
**The poster guard I added twice was unworkable and I never ran it.** It called
existsSync on docs/images/catalog/<name>.png. That directory is gitignored —
previews are generated locally, uploaded to the CDN and never committed — so the
check is false on every clean checkout and in CI. It would have stripped the
poster from all 168 pages, not the 13 with a missing file. It also referenced
REPO_ROOT, which does not exist in that file, so the script crashed on the first
item. I described this guard in two commit messages without once executing the
generator.

The poster is now gone entirely, which is the smaller and more honest fix. These
previews are autoPlay muted loop, so the poster is visible for a few hundred
milliseconds; 13 of the 168 files do not exist and the browser fetches the poster
before the video. Removing the attribute kills 13 x 403 and 168 needless image
requests, and there is nothing to keep in sync.

Also applied a complexity pass to the delivery encode:
- hasAudio() deleted. 17 lines and a spawnSync per item to choose between
  "-c:a aac" and "-an". ffmpeg ignores -c:a when the input has no audio stream;
  checked, exit 0, output carries no audio track.
- The 40-line spawn + Promise wrapper is execFileSync. Everything around it in
  that script is already synchronous.
- The duplicated poster lookup is gone with the poster itself.

Net 76 lines lighter. Generator runs clean, emits 168 pages, carry-forward intact.
2026-08-04 12:52:26 -07:00
ukimsanov fffc56c33b style: format changelog-weekly.ts
Left unformatted by the catalog-generator commit, which is the one file
Preflight was failing on. oxfmt --check is clean on it now.

Note for anyone reading the earlier claim: I said main fails oxfmt on 30 files.
That was my local run picking up no repo config and using its own defaults. CI
found exactly one file, and it was mine.
2026-08-04 12:24:43 -07:00
ukimsanov 4f1ace9f47 fix(scripts): actually commit the delivery encode
An earlier commit here said generate-catalog-previews.ts "has a web pass now".
It did not. I made that edit in a second checkout while investigating and never
brought it onto the branch, so the claim shipped in a commit message while the
code stayed as it was.

The fix itself is unchanged from what was described: the render output is a
master, and publishing it directly is what put 25 Mbps files on the docs CDN.
generateVideo now renders to <name>.master.mp4, runs one delivery pass at 1280
wide, CRF 28, faststart, keeping audio only when the source has it, then deletes
the master and logs the delivered size.

Formatted and linted clean. The Format check failing on this PR is pre-existing:
origin/main fails oxfmt --check on the same 30 files, none of which this branch
touches.
2026-08-04 12:21:43 -07:00
ukimsanov 663f3e8325 fix(docs): the poster guard again, for the catalog index this time
The previous commit claimed all 597 media URLs returned 200. They did not — 13
still 403'd, and I pushed that claim without re-running the check after the fix.
The message was wrong; this is the correction.

Two files carry a poster, not one. Guarding the page generator left
catalog-index.json still pointing at 13 .png files that were never produced,
which is a broken thumbnail in the catalog grid rather than a missing one.
catalogPreviewFor now returns undefined when the file is absent, the same rule
the page preview uses, and the 13 stale entries are dropped from the written
index.

Verified after the change, not before: 584 media URLs on this branch, 0 dead.
2026-08-04 11:53:37 -07:00
ukimsanov a6217f5ed1 fix(docs): stop claiming a poster image that was never generated
My catalog rebuild added `poster="<name>.png"` to every preview. Thirteen items
have an .mp4 but no .png, so the page asked for an image that does not exist —
and a browser fetches the poster before the video, so those were thirteen 403s
on load. The merged version did not have this; I introduced it.

The generator checks the file now, the same way it already checks before telling
a reader to read a comment header that may not be there. Thirteen pages lose the
attribute; the videos are unaffected.

Also uploads the three weekly changelog re-encodes to the path they are actually
served from — hyperframes/changelog-videos/, not the docs image tree — which I
had got wrong, leaving three dead links in the previous commit.

All 597 media URLs on this branch now return 200.
2026-08-04 11:45:41 -07:00
Miguel Ángel ac6fd5363b Merge pull request #3009 from heygen-com/release/v0.7.92
chore: release v0.7.92
2026-08-04 10:43:02 -07:00
ukimsanov 2a7d9bd0ad docs: rebuild the catalog generator, and show what background removal and HDR do
**The catalog generator destroys hand-written documentation on every run.** It
does `rmSync(dir, { recursive: true })` on both catalog folders before
regenerating, so any section a human added to a generated page is deleted the
next time anyone runs it. There is now a carry-forward pass that reads those
sections off the existing pages first and re-emits any heading the template does
not own. Note this prevents future loss only — the earlier regeneration in this
stack already removed what was there, and it is recoverable from git if wanted.

It was also clobbering `docs.json`: rebuilding the Catalog tab dropped its icon
and unlinked `catalog/index.mdx` from the sidebar entirely.

And 27 of 36 component pages told the reader to "see the comment header in the
file" when no such header exists. The generator reads the file now and only says
it when true.

On presentation: 1,365 table rows across 168 pages became 0. `## Details` was a
table whose rows were "Type: Block" and the duration; `## Files` was three
columns where most items have exactly one file. Both are one sentence now. The
preview leads the page instead of sitting under title, description, tags, a
warning and a credit — four of which Mintlify already renders from frontmatter.

**Two guides that showed nothing now show the thing.** Background removal plays a
real cutout with the matte magnified at the hair edge and at a shoulder a plant
overlapped, because those are where matting fails. HDR is the honest one: you
cannot show HDR brightness on an SDR page, so it shows the `ffprobe` verdict and
a measured round-trip — 4000 nits authored, 4012 back out — and says on screen
for all 26 seconds that it is not simulating anything.

Both films were checked before publishing: no black or frozen stretches, and read
at the real 590px docs column width rather than full size. Nav after regeneration:
307 pages, 0 dangling, 0 redirect collisions.
2026-08-04 03:08:04 -07:00
ukimsanov dc2d1697fe fix(scripts): make registry path containment filesystem-aware
Miguel's second P1 on #2975, and he is right that my first fix only closed half
of it. `resolve()` and `relative()` are string operations and do not follow
links. Registry items are copied in recursively with symlinks preserved, so a
PR shipping `escape -> /tmp/outside` and declaring `target: "escape/pwned.txt"`
passed the lexical check, `mkdirSync` followed the link, and `cpSync` wrote
outside the project.

Reproduced before fixing: the old predicate returned one allowed copy and the
file appeared outside the project. Both directions were exposed — a symlinked
`path` reads a runner file in just as readily.

Containment is now filesystem-aware. No existing component of a candidate may
be a symlink, and the candidate's real location — resolved through its deepest
existing ancestor — has to sit under the project's own real path. A symlink is
refused rather than followed, even one pointing back inside the project:
nothing in the registry needs one, and following it would mean trusting the
target not to change between the check and the copy.

The tests are real fixtures now instead of string cases, because a purely
lexical suite is exactly what stayed green through the bypass. Twelve of them,
covering a symlinked target directory, a symlinked source file, a deeper path
through a symlinked component, an inward-pointing symlink, plus the lexical and
absolute cases from before.
2026-08-04 03:06:32 -07:00
ukimsanov bd7ea5d5ce fix(scripts): contain registry manifest paths in the preview renderer
Miguel's P1 on #2975, and it is real. `catalog-previews.yml` triggers on
`pull_request` for anything under `registry/blocks/**` or
`registry/components/**`, so `registry-item.json` arrives from the pull request
and is untrusted. `mirrorRegistryTargets` joined `files[].path` and
`files[].target` under the temp project and called `cpSync` on the result, and
`join()` walks out of its first argument. A `path` of `../../../../etc/passwd`
reads an arbitrary runner file into the project — which the job then uploads as
an artifact — and a `target` of the same shape writes an arbitrary runner path.

Both sides are now resolved and rejected when `relative(projectDir, candidate)`
is absolute or starts with `..`. Traversal that lands back inside the project
still works, so `nested/../demo.html` is unaffected.

Containment lives in `scripts/registry-target-paths.mjs` rather than inline,
because the traversal cases have to be testable and importing
`generate-catalog-previews.ts` drags in the producer. `existsSync` is injected
so the decision cannot depend on whether the target happens to exist on the
runner. Eight tests, covering traversal on each field separately, absolute
paths on each field, the sibling directory that shares the project's prefix,
and traversal that returns inside.

Verified end to end on a real tree, not only in unit tests: a manifest asking
to read `../secret.txt` and write `../pwned.txt` produces neither file, while
the legitimate entry still copies.

I introduced the wrapper when I extracted this block for a complexity finding
earlier in the stack, and did not look at what it was joining.
2026-08-04 03:06:31 -07:00
ukimsanov 0dcd06ff62 refactor(scripts): extract mirrorRegistryTargets from prepareProjectDir
Inlining the registry-target mirroring pushed prepareProjectDir to cyclomatic
24, the one complexity finding fallow attributes to this branch — the other
eight are inherited and out of scope here.

Same behaviour, expressed as a filter chain in its own named function, so
prepareProjectDir returns to what it was and the new helper stays flat.
2026-08-04 03:06:31 -07:00
ukimsanov 79c4056a3f docs: make Catalog visual and reproducible 2026-08-04 03:06:31 -07:00
Vance IngallsandClaude Opus 5 ab00b040f6 fix(release): print the release-PR flow for stable versions
set-version printed 'git push origin main; git push origin v<version>' as the
way to trigger publish. That is the PRERELEASE flow. publish.yml's push trigger
is 'v*-*', so a stable tag push fires nothing, and stable publishes only from a
merged release/v* PR.

Following the old text put a release commit on main with an unpublishable tag:
publish never ran, and the stray tag then fails the next release's
verify_remote_tag check. Now prints the branch + PR commands and says not to
push the local tag, matching docs/contributing/release-channels.mdx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:48:53 -07:00
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
Vance Ingalls 71fd96bbf1 Merge pull request #2854 from heygen-com/feat/canary-rollouts
feat(core): percentage-based canary rollouts + calibration experiment
2026-08-03 22:35:37 -07:00
Miguel Angel Simon Sierra ce7d75dbaa fix(registry): animate mk card offsets with transforms, not top/left
mk-background and mk-clone-wall-transition tween the card's `top`/`left`.
Layout properties snap to integer device pixels, so the move stutters under
the seek-by-frame capture engine (lint: gsap_non_transform_motion). Both
cards sit at top:0/left:0 in CSS, so the values carry straight over to x/y,
and in clone-wall the later scale composes cleanly with the translate.
Re-rendered both and diffed frames against the previous output — identical,
as intended: this changes how the motion is computed, not how it looks.

Adds scripts/lint-registry-items.mjs (bun run lint:registry-items), which
mounts each item into a throwaway project and lints it. Registry items ship
as `<name>.html`, so `hyperframes lint <dir>` fails with "No composition
found" and these items had never actually been linted — which is how both
errors reached main. Verified the script reproduces the original failure on
the pre-fix source.

Left as a local command rather than a CI gate for now; wiring it up needs
two prior fixes, noted in the PR.
2026-08-03 10:14:37 +02:00
James Russo d6191965cf fix: pin release publishing to merge commit (#2959) 2026-08-02 21:32:08 -07:00
Vance IngallsandClaude Opus 5 6f0df2640b fix(cli,studio,core): close five R5 telemetry and canary findings
- A long-lived preview cached its telemetry posture in two places
  (readConfig and shouldTrack). Running `telemetry disable` in another
  terminal left it resolving canaries and injecting the CLI id for hours.
  Both caches are now dropped together at a request boundary.
- Studio minted and shipped a telemetry id for every render regardless of
  the browser profile's opt-out, and the server emitted the outcome under
  CLI policy, which cannot see localStorage or DNT. The browser now sends
  an explicit telemetryOptOut, distinct from an old client's omission.
- Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard,
  so even a loopback bind accepted a hostile Host. The guard now holds for
  loopback binds and, on a LAN bind, admits only names this machine
  answers on.
- sunsetAfter had no reader of the current date. A scheduled workflow runs
  scripts/check-canary-sunset.ts weekly, so a failure lands on the
  rollout's owner rather than on an unrelated PR author.
- The install-state seed memo outlived `rm -rf ~/.hyperframes`,
  resurrecting a cleared cohort. Removed; it only saved a read on a
  readConfig cache miss.

Docs updated for the Host rule and the 100% exclusion carve-out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:21:41 -07:00
ukimsanov c62bd4c454 fix(studio): harden preview recovery 2026-07-27 23:55:06 -07:00
ukimsanov 3af9a7df2d refactor(studio): reuse player probe errors 2026-07-27 22:35:09 -07:00
ukimsanov 0faeb42f86 test(studio): mock composition thumbnails in smoke 2026-07-27 22:25:27 -07:00
ukimsanov b0d3164ddb feat(registry): add media treatment overlays 2026-07-24 18:42:23 -07:00
James RussoandMiao Yang 696cbdbbd0 chore(skills): package Codex plugin upload (#2668)
* chore(skills): package Codex plugin upload

* chore(skills): harden Codex plugin content

* fix(skills): satisfy plugin quality gates

* fix(skills): address plugin packaging review

* fix(plugin): simplify asset validation

* fix(skills): correct embedded-captions catalog count to 35 after nightcity removal

The nightcity theme removal left SKILL.md claiming 36 identities in
four places, including the frontmatter description the router reads.
The catalog now has 35 entries (10 classic + 25 themed).

---------

Co-authored-by: Miao Yang <miao.yang@heygen.com>
2026-07-22 00:41:40 +08:00
James ab8b50afd7 refactor(repo): resolve changed-code audit 2026-07-20 11:59:57 -07:00
James 3aa2404747 refactor(cli): centralize process lifecycle 2026-07-20 09:04:03 -07:00
James 942db16fcd fix(cloudrun): publish adapter contract 2026-07-18 20:17:01 -04:00
James 3454a80eb2 test(repo): execute packed subpaths 2026-07-18 18:27:44 -04:00
James 8d9d9c016e refactor(repo): centralize package subpaths 2026-07-18 15:58:23 -04:00
James 562544f68d refactor(core): own edit protocol contract 2026-07-18 13:24:57 -04:00
James RussoandJake Moran e96ebd74de feat(skills): add changelog-video skill for repo-native CC + Codex discovery (#2552)
Packages Jake Moran's changelog-video pipeline (v1, validated end-to-end
by Home on the Jun 23-29 range) as a repo-native skill set that Claude
Code (.claude/skills/) and Codex CLI (.agents/skills/) auto-discover the
moment the repo is opened. No install step; run the skill against a
changelog markdown for a given git range and it produces a lint-clean,
seam-gate-green 1080x1080 MP4 (~45-60s, Annie VO, mock-UI visualizations,
caption rail) end-to-end.

Six skills added byte-identical in both mirror dirs:
- changelog-video (pipeline entry point)
- motion-doctrine (carries seam-stamp.mjs + seam-gate.mjs)
- cut-the-curve, captions-overlay, seam-craft, oversized-cursor

Layout:
- .claude/skills/  - Claude Code project-local auto-discover
- .agents/skills/  - Codex CLI project-local auto-discover (verified via
                     Magi's clean-home Codex 0.144.3 repro; NOT .codex/skills/)

Fonts, animated background (12 MB), house BGM (5 MB), lexicon, and
align-captions ship inside the skill dirs. .gitattributes routes only
.claude/skills/**/*.{mp4,mp3} + .agents/skills/**/*.{mp4,mp3} through
LFS — narrowly scoped so unrelated Player, Studio, registry, and
marketplace media stay put. HeyGen CLI auth is the one credential the
skill needs; Node >= 22, ffmpeg, and headless Chrome are documented
alongside in both READMEs.

.gitignore: rewrites .claude/ and .agents/ blocks to keep agent-installed
skill hygiene while re-including the six repo-native skill dirs plus
README.md.

CI:
- Extends changes.skills filter to match .claude/skills/**,
  .agents/skills/**, scripts/lint-skills.ts, and scripts/check-skill-mirror.mjs.
- New 'Skills: project-native lint + mirror' job runs the extended
  lint-skills.ts (schema-driven; required { name, description } + optional
  { license, allowed-tools, metadata }, name pattern check, description
  length check) plus a new check-skill-mirror.mjs byte-integrity script
  (24 mirrored files must match; README.md deliberately per-CLI).
- Wired into 'bun run lint' locally.

Frontmatter validator:
- Rejects unsupported top-level keys (catches category:-style drift).
- Requires name + description.
- Validates name pattern (^[a-z][a-z0-9-]{0,63}$) and description shape
  (non-empty, <=1024 chars).
- Missing frontmatter block itself is a first-class error.

Also strips unsupported top-level 'category:' frontmatter from Jake's
motion-doctrine and cut-the-curve SKILL.mds (both mirrors), rewrites the
TTS invocation from ~/.claude/skills/media-use/... to the tracked
skills/hyperframes-media/scripts/heygen-tts.mjs, swaps npx hyperframes@latest
for the repo-local CLI in the gate step, and fixes a lint issue in Jake's
seam-gate.mjs (ternary-for-side-effect -> if/else).

Validated end-to-end by Home on Jun 23-29 (MP4 posted in C0ACCNHLG3U
thread 1784181166.041319). Independently reviewed R1/R2/R3 by Magi.

Co-authored-by: Jake Moran <jake@heygen.com>
2026-07-16 17:29:19 -04:00
WaterrrForever b9be0b2625 feat(skills,studio,media-use): the intent layer, review loop, and user memory — BRIEF.md, companion mode, recipes; /website-to-video folds into /product-launch-video (#2133)
* feat(studio,cli): per-frame board comments, self-refreshing storyboard, status-aware preview landing

Per-frame comment boxes on the storyboard board batch into
.hyperframes/frame-comments.json (a resubmit wins per frame; unconsumed
comments on other frames are kept). Submitted-but-unconsumed comments
stay visible — a toolbar banner plus a per-tile echo — until the agent
consumes the file; the banner also says what to do next (reply anything
in the agent chat).

The board keeps itself current: GET /projects/:id/signature exposes the
watcher-cached project signature, the storyboard payload carries the
signature it was derived from, and the view polls at 2s (hidden tabs
skipped, re-checked on visibility), refetching in place with no loading
flash. Posters bake the signature into their URL so tiles fill in as
sketches land and a poster that failed mid-write retries on the next
version; the empty state upgrades itself when STORYBOARD.md appears,
and its handoff prompt now points the agent at the review loop and uses
the parser's real status vocabulary (outline, not planned).

preview lands the browser on the storyboard view while the board is the
review surface — any frame built, or pure planning (srcs declared, none
on disk yet) — and on the timeline once the video is assembled.

* feat(skills): the review loop — plan, sketch, build as one shared process

hyperframes-core/references/review-loop.md is the single source for the
three-pass collaborative review: the plan proposed on a live board
(§ 1), wireframe sketches marked built with one layout question (§ 2 —
real words on plain blocks, run no CLI; a confirmed board is itself a
valid deliverable when the user asked for a storyboard, not a video),
the build dressing confirmed layouts (§ 3, worker or inline), and the
final look (§ 4). Autonomous runs skip every gate and keep one question
before render.

The three narrative workflows' Steps 3/4/6 collapse to references plus
their sketch stand-ins (captured-asset blocks for product-launch-video,
plain code panels for pr-to-video); the confirmed-sketch handoff stays
in each frame-worker prompt. general-video plans on a board for
multi-scene narrative pieces in collaborative mode — its sketch pass is
layout-before-animation with the user watching. The router treats
"I want a storyboard" as a process request rather than a route, and
closes exploratory intake by recommending a route plus how the run will
review.

The supporting contracts land next door: the comments channel (silent
submit, one reply picks it up, check the file before the words) in
brief-contract § 1; the sidecar schema and the built status rung in
storyboard-format; the mode question asked first and alone in the three
workflows' Step 0.

* feat(media-use): user memory — remembered preferences and frozen recipes

Two tiers of memory on media-use's existing two-tier storage split.

Preferences (lightweight): confirmed brief answers — destination, aspect,
language, mode, voice, style preset — recorded to the project's
.media/preferences.json (committed, the team inherits it) and promoted
to the personal ~/.media/preferences.json once the same value is
confirmed in two different projects (a sightings ledger accumulates the
cross-project evidence user-side, since project files can't see each
other). prefs.mjs get/record; merge reads project-over-user; a changed
value restarts its provenance.

Recipes (heavyweight): one approved run frozen as a named, versioned
bundle — frame.md, the storyboard skeleton (structure kept: durations,
transitions, srcs, Video direction; statuses reset to outline; content
blanked to per-frame fill-ins naming the beat's role), and the confirmed
brief values. Named folders, not content hashes: re-freezing bumps
version and archives <name>@v<N>; a freeze is already confirmed, so it
promotes to the user tier immediately. recipe.mjs freeze/list/use, plus
resolve --type recipe --entity <name> delegating like grade/lut.

16 new node --test cases; the media-use lib suite is 168/168.

* feat(skills): wire user memory into the brief and the review loop

brief-contract § 2 gains Remembered defaults: read the merged
preferences before Round 2 and let a remembered value become the
recommended option with a receipt naming its source project. Memory
changes the default, never the question — every ask-marked field still
gets asked, and what the request says this time beats what was picked
last time. Record only what the user actually confirmed (a defaulted
voice nobody chose is not an answer; a "go" that accepts the
recommended defaults is). The first record announces itself once;
after that the receipts carry the reminder. In autonomous mode a
remembered value becomes the decided value, receipt included.

The three narrative workflows read the remembered defaults before
Round 2, record the confirmed answers at the Step 0 gate, record the
chosen preset at the Step 2 gate (pr-to-video excepted — its preset is
fixed), and fall back to the remembered voice when the request names
none. general-video's discovery reads the same defaults.

Recipes wire in at both ends: Step 0 checks for a matching recipe
before the mode question — one question, plural-aware, and adopting
one fills the brief, skips the design step, and drafts the storyboard
from the frozen skeleton while every review gate still runs. The
review loop's final look (§ 4) offers the freeze once after approval,
and the confirmation teaches the recall phrase — the name is something
the system reminds the user of, never something they must remember.
The router recognizes a named recipe or "like last time" as a route.

* docs(skills): the sketch pass names check, not the deprecated validate

* feat(skills): intent-layer references — process, route briefs, capability menu, BRIEF.md format

* feat(media-use): brief skeleton as the recipe's fourth artifact; flow/storyboard preference keys

* feat(skills): the intent layer conducts every brief — workflows execute BRIEF.md

* feat(skills): retire the mode preference key; sync catalog surfaces for intent layer

* refactor(skills): dedupe router vs intent-layer guidance — one owner per rule

* feat(skills): the design ask — own spec, pick by eye from showcases, or defer

* docs(skills): the design ask says the honest line on capture routes

* feat(skills): product-launch-video absorbs website-to-video as the tour angle

* refactor(skills): keep product-launch-video pristine — a tour is brief intent, not a pipeline branch

* feat(skills): production loop + genre lenses; general-video goes freeform (route yours, laws hold)

* refactor(skills): /hyperframes is the front door - route tables and scope lists leave the workflows

* docs(skills): review-loop pass across skill catalog

* fix(cli): pass project dir to openStudioBrowser in background-server path

* feat(skills): add pitch-round reference - verbalized sampling concept gate

* feat(skills): wire pitch round into intent layer - completeness triage + route eligibility

* feat(skills): editorial capability recommendations, handoff disciplines, menu-probe split

* feat(skills): pitches carry their machinery; source-only-formed requests pitch the telling

* feat(skills): companion goes director - ceiling treatment plus blueprint/rule citation discipline

* fix(scripts): sandbox npx-leak guard - private npm global prefix keeps npx on the branch CLI

* chore(skills): resync manifest hash after formatter pass reflowed general-video tables

* fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset records require workflow scope

Two holes found by a live companion-run freeze: the agent-supplied --workflow
contradicted the run's actual workflow (recipe.json said faceless-explainer,
brief-skeleton said general-video), and the style_preset lookup missed because
the preference had been recorded under the bare key.

- freezeRecipe resolves the workflow from BRIEF.md frontmatter; the flag is a
  fallback for briefless projects and a contradicting flag is ignored (noted).
- recordPreference refuses a bare style_preset — the scoped key is the only
  writable shape; freeze tolerates legacy bare records via read fallback.
- review-loop § 4 / media-use SKILL / brief-format wording follow the machinery.
2026-07-15 21:19:14 +08:00
Miguel Ángel 3dcc6e6a80 fix(release): resolve packed-consumer transitive deps to local tarballs (#2396)
verifyPackedConsumer installs each package as a file: tarball, but each tarball pins its inter-@hyperframes deps to the exact release version. On a release bump that version is not on the npm registry yet, so bun resolves the transitive deps from the registry and fails. Add an overrides map forcing every @hyperframes/* to the sibling local tarball, making the check self-contained pre-publish. Exercised by the v0.7.57 release PR.
2026-07-13 23:51:17 -04:00