* feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare CLI
Add color grading to media-use as first-class resolve types plus a faithful
comparison command. All local, offline, deterministic — no model, no GPU.
- resolve -t grade / -t lut: produce a data-color-grading block (or a frozen
.cube). Look cascade: core preset (no file) -> bundled .cube library ->
parametric buildCube. Emitted .cube is Rec.709 and validated against core's
colorLuts constraints (LUT_3D_SIZE <= 64) before it is frozen.
- smart grade (grade --for <media>): ffmpeg signalstats -> adjust suggestion
(exposure / contrast / white balance), surfaced with the measured evidence on
stderr as a starting point; never auto-applied.
- hyperframes grade-compare: renders N candidate grades onto a reference frame
through the real runtime shader into one labeled comparison PNG, so an agent
picks a look without opening Studio. Prepends an "original" baseline cell by
default (--no-baseline to omit). Shares the headless-capture pipeline with
snapshot via capture/captureCompositionFrame.
- media-use SKILL: proactive "media opportunity pass" guidance (grounded
signal -> offer, ask once, surface don't mutate).
Verified: media-use 116/116, grade-compare 7/7, snapshot 9/9, lint + format
clean, full build green, comparison renders end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* test(cli): narrow grade-compare baseline assertion off unknown-typed grading
Assert the whole cell via toEqual instead of reaching into .grading.preset /
.grading.lut on the unknown-typed field, keeping the test typecheck-clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(media-use): agent-authored LUTs via --params + validate --from cube; never-read-.cube guardrail
- resolve -t lut / -t grade --params '<json>': build a parametric .cube from
explicit params (bypassing the intent cascade), validate, and freeze in one
step. --intent becomes the optional description. Lets an agent commit a look
it computed itself.
- --from <file.cube> now validates the ingested LUT for lut/grade types and
rejects an invalid/oversized cube (no partial write) — the escape hatch for a
LUT the agent generated with its own code.
- SKILL.md: hard rule to never read a .cube body into context (~size^3 lines,
zero legible signal) — inspect via grade-compare (see it) or cube-validate
(ok/size), read the manifest description for meaning; plus both authoring
paths and the parametric-vs-film-stock ceiling note.
Verified: media-use 116/116, lint + format clean; smokes — --params builds a
valid frozen cube, grade --params returns a lut block, bad JSON and an oversized
--from cube are both rejected with no stray file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(cli): grade-compare validates referenced LUTs, warns on no-op cells, caps candidates
Bug-bash follow-ups — grade-compare silently accepted bad input:
- Validate LUT *content*, not just existence: each referenced .cube is parsed
with core's parseCubeLut (now exported from @hyperframes/core) and rejected
with a per-cell error ("LUT for \"<label>\" is not a valid .cube: ..."). A
file that exists but isn't a valid cube no longer renders a silent no-op cell.
- Warn on inactive cells: a grading that normalizes to inactive (e.g. a
malformed {lut:12345}) emits a stderr warning naming the cell; the
auto-prepended "original" baseline is intentionally inactive and stays silent.
stdout remains valid JSON.
- Cap candidates at 16 (excluding baseline): over-cap input renders the first N
and reports {truncated:true, total:M} on stdout + a stderr note — no silent
drop, no unbounded giant sheet.
Verified: grade-compare 10/10; non-cube LUT → clear error; {lut:12345} → warning
+ ok; 20 cells → cells=17 truncated total=20; valid runs unchanged. Lint/format
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(cli): general `hyperframes compare` visual-variant primitive
Generalize grade-compare's "render N variants → one labeled sheet → the agent
looks and picks" loop into a standalone command that works on ANY variation
(font, layout, motion, grade, whole compositions) — the tool never needs to
know what differs.
- `hyperframes compare <path...> [--at <sec>] [--labels a,b,c] [--out] [--cols]
[--json]`: renders each agent-authored composition variant through the real
runtime (captureCompositionFrame) and stitches one labeled comparison sheet +
JSON ({ok, sheet, rendered, variants, truncated?/total?}). 2+ paths required;
caps at 16 with loud truncation. It presents, it does not judge — choosing is
the caller's job.
- Factored the shared "render a labeled set → contact sheet" path so compare,
grade-compare, and snapshot all sit on it (no duplication). grade-compare is
now the first color-specific specialization of this primitive.
- New pathArgs util + contactSheet test; hyperframes-cli SKILL documents compare
as the agent's "see your own renders and choose" primitive.
Verified: 26/26 across compare + grade-compare + snapshot + contactSheet (no
regressions); compare renders 3 variants into one visibly-distinct labeled
sheet; 2+-path error path clean; lint/format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(ci): green the skills CI — skip ffmpeg tests when absent, oxfmt markdown
The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
design — skills tests are meant to be node-builtin-only). The grade-analyzer +
smart-grade tests shell to ffmpeg and were failing there with ENOENT. Guard
them to skip when ffmpeg isn't on PATH; they still run locally / where it is.
Also oxfmt README.md + hyperframes/media-use SKILL.md (the whole-repo
`oxfmt --check .` Format job caught markdown left unformatted by the rebase
conflict resolution).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(ci): skip core-conformance test when tsx is unavailable
The "Test: skills" CI job installs no deps, so the normalizeHfColorGrading
conformance test (which imports core's TS via `node --import tsx`) failed there.
Guard it to skip when tsx can't resolve; runs locally / in the deps-installed
Test job. Completes the skills-CI greening (the ffmpeg guards handled the rest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(cli): escape grade-compare src double-quotes (CodeQL XSS) + Windows-safe compare test
- grade-compare built `<img src="...">` (double-quoted) with the single-quote
escaper, leaving `"` unescaped — a `"` in the frame path could break out
(CodeQL: incomplete HTML attribute sanitization). Use escapeXml for src.
- compare label test hard-coded POSIX paths that can't match on Windows; assert
the derived labels (the subject); path resolution is covered elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* refactor(media-use): generate LUT library from params (drop committed .cube files)
The 3 bundled .cube files were 733 lines each (2,199 total) and were themselves
buildCube output — pure repo bloat. Replace with compact per-look params in
luts/index.json, generated on resolve; add an optional `url` for future scanned
LUTs to be CDN-hosted + downloaded on demand (freezeUrl) instead of committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(media-use): serve library LUTs from CDN on-demand (static.heygen.ai/luts), params fallback
Looks now carry a CDN `url` (hosted at s3://heygen-public/luts → static.heygen.ai/luts/<id>.cube);
resolve downloads + validates + freezes on demand, like bgm/image. `params` stays
as the deterministic offline fallback (--local-only, or if the download fails), so
resolution is never blocked on the network. Provider prefers url, falls back to params.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2041 review — atomic LUT writes, compare telemetry, follow-ups
- Atomic .cube writes: library provider (url + params) and the parametric
generator now write to a .tmp path, validate, then rename, so a crash can
never orphan an invalid .cube at the final path (was validate-after-write).
- track("media_use_resolve") now emits provenance.via (url/params-fallback/params).
- grade-compare + compare: --timeout flag (was hardcoded 5000) and a
media_use_compare event (cells, truncated, total, render_ready_timed_out);
openSettledCompositionPage now surfaces the render-ready timeout.
- compare staging skips node_modules/.git; --for gets an upfront existence check.
- Rec.709 luma comment; HYPERFRAMES_ANALYZE_TIMEOUT_MS override; measured note
uses basename; LUT s3 hosting moved from index.json into luts/README.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2066 fixed sub-composition data-variable-values on the render path for a single
mount, but the reusable-template pattern from #2064 (the same sub-comp mounted
multiple times with different values) still diverged from preview/snapshot:
every mount shared one __hfVariablesByComp key and one CSS scope selector, so
the last mount's values clobbered the earlier ones and all-but-one instance
rendered blank.
The producer now assigns per-instance runtime composition ids
(assignBundledRuntimeCompositionIds) and threads hostIdentityMap into the shared
inliner, mirroring the preview bundler. The shared inliner's default
buildScopeSelector already scopes by the runtime id, and timelines remap to it
via the scoping proxy, so each instance's variables, CSS, and timeline land
under its own id.
Pixel-verified end to end: two mounts of one sub-comp with different
data-variable-values now render their own content (green CARD_A / blue CARD_B),
matching snapshot; single-instance behavior is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
render left window.__hyperframes.getVariables() empty inside every
sub-composition mounted via data-composition-src, so each instance rendered
its declared JS defaults instead of the per-instance data-variable-values.
preview/snapshot injected them correctly, so the composition looked right in
every authoring/QA surface and then rendered wrong content silently (exit 0).
Any template-library workflow (reusable sub-comp scenes parametrized per
video) shipped placeholder/default text in the final MP4.
The plumbing already existed on main: htmlCompiler passes
readVariableDefaults/parseHostVariables and populates result.variablesByComp,
and the CSS-custom-property path (emitRootCompositionVariableStyles) reaches
the render. But the render compiler emitted only the CSS vars and never the
JS table window.__hfVariablesByComp that the scoped getVariables reads, while
the preview bundler (htmlBundler) did -- so getVariables() returned {} only
during render.
Fix, so the paths cannot drift again: buildVariablesByCompScript, colocated
with the reader in compositionScoping.ts and shared by both compile paths.
htmlBundler now calls it instead of an inline string; htmlCompiler injects it
before the inlined sub-comp scripts, using the already-populated
result.variablesByComp.
Verified end-to-end: a sub-comp painting its background from a color variable
now renders the injected value under render, matching snapshot; previously it
rendered the default. 3 new producer tests; 89 htmlCompiler + core-compiler
tests pass.
Closes#2064.
- slugify: replace the anchored alternated trim regex (/^-+|-+$/g) with a
character-scan trim — CodeQL js/polynomial-redos blocker.
- readRenderOverrides: fold the readOverrides wrapper into the exported
function (one name, no pass-through).
- getVariables: deduplicate declarers with a Set, matching
injectCompositionCssVariables.
- Move the tokenSlug import to the top of the file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live testing of the compile-time variable emission surfaced four gaps:
- The producer render path never emitted the compile-time stylesheet (only
the preview bundler did), so eval-time reads — GSAP .from immediateRender,
top-level getComputedStyle — saw undefined vars in rendered output. The
producer's inlineSubCompositions now calls the shared
emitRootCompositionVariableStyles and passes the variable hooks.
- --variables overrides weren't visible at eval time. They now thread from
the orchestrator / distributed plan through compileStage into the emitted
rules (window.__hfVariables still covers script reads).
- Per-declarer rules anchored on data-composition-id, which two inlined
instances of one sub-composition share — instance A's rule restyled
instance B, and a rule directly on the declarer defeated the host's
inherited data-variable-values. Rules now anchor on per-instance
data-hf-var-scope markers and layer nearest-host values over declared
defaults, mirroring the runtime loader.
- Emission ignored authored CSS; a declared default now yields to a var
already defined in an authored <style> block (define-if-absent, matching
the runtime injection).
Also: the figma importer emits background-color (longhand) for solid fills.
GSAP backgroundColor tweens cannot read a var() through the background
shorthand — its pending-substitution longhands serialize empty, so .from
captured nothing and settled on transparent (pre-existing GSAP interaction,
reproduced with no composition variables involved).
Validated live: eval-time default + override, .from + override, two-instance
host branding, authored :root precedence, SDS brand-loop pixel parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brand-loop live test (SDS duplicate, plans/figma/brand-loop-test-plan.md)
proved the recolor chain end-to-end and surfaced three gaps:
- runtime now defines every declared composition variable as a CSS
custom property (document root at init + scoped sub-comp hosts in the
loader), so imported var(--slug, literal) fills resolve live — without
this the frozen literal always won and variable-driven rebranding
could not propagate. Slug kept byte-compatible with the figma
importer (parity test). render --variables overrides win.
- figma component --name: variant frames are often all named
'Platform=Desktop' and slug-collided across imports.
- imported fragments carry data-hf-snippet and the project linter skips
composition-root rules for them.
- /figma skill documents the field-tested non-Enterprise tokens path
(MCP get_variable_defs joined with REST boundVariables ids).
Shared-helper extractions (injectScopedStyles, flattenedRoot module,
parseHostVariableValues, rasterizeFallback, shapeCss) satisfy the
dedup/complexity audit the runtime changes tripped.
Validated live: brand-loop renders purple from the attribute alone (no
manual :root); 118 figma + 662 runtime/compiler + 331 lint tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(engine): resolve relative data-start references in video-frame extraction
<video data-start="intro"> (a relative reference to another clip's end) is
resolved by the browser runtime but parseVideoElements/parseImageElements did a
raw parseFloat, yielding NaN start/end. The FrameLookupTable active-window
checks (start <= t <= end) are then always false, so the clip is never injected
and composites BLANK in the final render — while lint/validate/inspect/snapshot
and the live preview all look fine. The docs' Relative Timing section teaches
exactly this pattern on <video>.
Share the pure reference-syntax parser (parseStartExpression) out of the runtime
resolver into @hyperframes/core, and resolve references in the extractor against
the linkedom document it already holds: a reference resolves to the target
clip's resolved start + its duration (data-duration or data-end) + offset,
mirroring the runtime. Cycle-guarded; an unknown target or unknown duration
falls back to the target's start / 0 (never NaN), matching runtime semantics.
Natural-media-duration-only targets aren't known at parse time (same limit as
the runtime's fallback). parseImageElements gets the same fix.
Runtime resolver behavior is unchanged (its 25-case suite still passes).
* chore: re-trigger CI to refresh a stuck CodeQL aggregate check
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling
WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload
Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
The timing compiler scanned raw HTML with tag regexes that weren't
comment-aware, so a comment or script merely mentioning `<video>`/`<audio>`
was rewritten as a real element — injecting id/data-start/data-hf-auto-start
into the comment text. That phantom attribute then tripped the probe stage's
substring check (`html.includes("data-hf-auto-start")`), launching an
unnecessary browser probe on every render with an unexplained empty reasons list.
- Mask comments, <script>, and <style> regions before the tag scan, then
restore them verbatim (compileTimingAttrs, extractResolvedMedia).
- Replace the probe's substring match with a DOM query
(video[data-hf-auto-start]) and add "auto-start video(s)" to the reasons list.
Sub-composition scripts run inside a wrapper that passes the SCOPED
__hyperframes (per-instance getVariables) as a bare script param, while
`window` is a Proxy. That proxy intercepted only __timelines, so
`window.__hyperframes` fell through to the HOST page's base
__hyperframes — whose getVariables reads the host's variables, not this
instance's. So the two documented spellings diverged: the bare
`__hyperframes.getVariables()` param returned the correct per-instance
values, but `window.__hyperframes.getVariables()` returned the wrong
(host / empty) ones, silently rendering every reused instance with the
first instance's content (or defaults).
docs/concepts/variables.mdx already promises both forms "work in both
top-level and sub-composition scripts ... each instance sees its own
resolved values" — the runtime just didn't honor it. Reported directly
(a user lost significant debugging time across three parametrized
sub-comps before discovering the bare param was the only form that
worked), and matches an earlier deferred finding that getVariables()
returns {} for reused sub-comp instances.
Fix: the scoped `window` proxy now returns the scoped __hyperframes for
`prop === "__hyperframes"`, so window.__hyperframes.getVariables() and
the bare param resolve identically to this composition's own variables.
The scoped variant is Object.assign({}, base, { getVariables }), so all
other __hyperframes members still pass through to the base unchanged.
Test: two new executed-wrapper cases (new Function(...)(fakeWindow)) —
window.__hyperframes.getVariables() now returns the per-comp variables
instead of the TOP-LEVEL-LEAK host value, and a non-getVariables member
(fitTextFontSize) still reaches the base. Full core suite (1092) passes.
Post-release review of media-use ↔ figma coupling (spec §13.1):
- figma asset imports now regenerate .media/index.md, the agent-readable
inventory media-use maintains — format locked byte-identical via a
cross-runner parity test against media-use's own index-gen.mjs
- figma asset --description/--entity land in the manifest record, the
index table, and <img alt>; component rasterize auto-describes with
the node name. Named brand marks become visible to media-use's
resolve --entity lookups.
- spec §13.1 records the review verdict (loose coupling correct) and
the follow-up queue (shared media-ledger module, global cache for
figma assets, media-use version-keyed idempotency)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate
A committed moveElement wrote data-x/data-y but nothing rendered them:
hosts shimmed CSS translate, which GSAP folds into the cached transform
at first parse and then discards on the animated axis at every seek —
dragging an animated element kept only the un-animated axis.
Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is
never read, folded, or cleared across seeks and composes natively with
the animated transform. So:
- moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y)
- the runtime (new core runtime/positionEdits.ts, applied at timeline
bind — after GSAP parse) renders translate = (data-x − base), a pure
delta that composes with GSAP tweens, tl.set positions, and CSS alike
- applyDraft now drives the drag preview through the same translate
channel (the --hf-studio-dx/dy vars had no consumer outside authored
Studio bridges), and commitPreview mirrors the committed move onto
the live element so it holds without an srcdoc reload
Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts
(real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated,
Y-animated, and static elements hold both edited axes across the full
seek range. New subpath export @hyperframes/core/runtime/position-edits.
Known limitation (documented): a tween created lazily at runtime that
first-parses a marked element after apply folds the edit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): harden position-edit rendering and the drag draft channel
Fixes six issues from adversarial review of the moveElement stack:
- Runtime: apply position edits at init as well as at timeline bind, so
committed moves render in compositions with no usable GSAP timeline
(CSS/WAAPI-animated or fully static) — previously the apply was
unreachable outside the boundDuration > 0 bind branch and the edit
silently vanished from reloads and renders.
- Runtime: guard bind-path re-apply against post-fold double-apply — if
the previously written translate was consumed externally (a lazily
created tween folding it into GSAP's cached transform), skip instead
of re-setting it on top ({force} escape hatch for editor commits).
- Adapter: stop writing the --hf-studio-dx/dy custom properties during
drags — compositions with the documented var-consuming drag-bridge
CSS moved by twice the pointer delta (var transform + new inline
translate). The inline translate is now the only draft channel;
deltas accumulate in adapter fields. Docs updated to match.
- Adapter: switching applyDraft to a new id reverts the abandoned
element's draft translate instead of leaving it displaced with no op.
- Adapter: cancelPreview restores the raw inline translate (removing it
when there was none), so a stylesheet-authored translate is never
promoted to a permanent inline style.
- Adapter: commitPreview reverts the draft and clears state when
dispatch throws, instead of leaving the element shifted by an
uncommitted draft.
Cleanups: reuse readCurrentTranslate from the core module (was a
verbatim copy), drop the dead __hfApplyPositionEdits window hook.
Browser acceptance test now also covers the GSAP-free composition path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry
Addresses PR #1875 review feedback (Rames, Miga):
- Prime the element's GSAP transform parse (gsap.getProperty) before the
first translate apply — positioned tl.set()s and tweens that first
RENDER after the apply now reuse the cache instead of folding the edit.
This closes the lazy-first-parse fold-loss for any page where GSAP is
loaded at apply time; the residual limitation is GSAP itself loading
after the apply. Proven by the extended browser acceptance test.
- Emit position_edit_fold_skipped analytics at the fold-guard skip site
so the residual degradation is observable instead of silent.
- Browser acceptance test: add a both-axis-animated element (the shape
that originated the per-axis loss) and a positioned tl.set() element,
asserted across the full seek range.
- Simplify the num() null guard (review nit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): reroute /figma by capability - REST/CLI for phases 1-3, MCP for 4-5 (M4)
Rewrites the skill from MCP-first to the spec 2 split: asset/tokens/
component route through the hyperframes figma CLI (FIGMA_TOKEN), motion/
shaders stay agent-driven over MCP (no REST equivalent). Adds two-
credential guidance, Starter rate-limit tactics (recursive:true, raw-
response cache, opt-in screenshots), the 7.1 binding flow (tokens before
components, one ask per unknown library, never value matching), and the
shader manual-export default. Catalog blurbs updated in lockstep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): register figma component subcommand
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): add storyboard-to-animatic guidance to /figma
Field-tested against a real 26-scene storyboard section: the parsing
grammar (frame-sized nodes incl. loose rectangles = scenes, x-order =
time order, TEXT below the strip = director notes paired by x-overlap),
batched still export (chunk ~4 ids per render call - big frames timeout
past ~12), a note-verb -> transition vocabulary (EXPLOSION/SLIDE/MORPH/
CYCLE), and the stills-vs-component routing rule for within-scene motion
notes. Catalog blurbs updated in lockstep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): storyboard frames are keyframes, not slides
Field-tested against a second real storyboard section: frames sharing an
element (matched by name, else geometry similarity) define that element's
states through time - tween the element between states, crossfade only
when pixels genuinely differ, enter/exit unmatched children, tween frame
backgrounds as a color track. Stills demoted to fallback for frames that
don't decompose. Validated live: a 4-frame logo-rise reconstructed as one
element with four keyframes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(figma): self-explanatory first-run experience + mintlify guide
- NO_TOKEN/BAD_TOKEN errors now carry the full one-time setup (mint URL,
read-only scope checklist, persist hint) instead of a bare pointer
- figma subcommands print clean guidance on typed client errors, not a
stack trace (shared withFigmaErrors boundary)
- CLI help gains component subcommand, FIRST-TIME SETUP and WHAT TO
EXPECT blocks
- /figma skill: preflight the token before the first CLI call and walk
the user through setup up front; narrate landed-artifact + next action
at every step
- new docs/guides/figma.mdx (setup, per-phase walkthroughs, provenance,
troubleshooting table) wired into docs.json nav
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(figma): review fixes — missing withFigmaErrors imports, 401/403 semantics, docs accuracy
- tokens.ts/component.ts called withFigmaErrors without importing it
(tsup doesn't typecheck, so every invocation shipped as an immediate
ReferenceError); imports added, tsc --noEmit now clean
- error boundary widened to all Errors so bad-ref/bad-format input
errors print their message instead of a stack trace
- 401 no longer claims 'missing scopes' (figma signals that as 403);
new FORBIDDEN code maps non-variables 403 to scope/access guidance
- docs: asset/component refs require a node id (bare fileKey is
tokens-only), example snippet matches real output, FORBIDDEN row
- skill: preflight counts a project-.env token as configured (CLI
auto-loads it); BAD_TOKEN/FORBIDDEN guidance split
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): present figma errors via standard errorBox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
resolveBindings: scan the full tree (boundVariables + style ids, alias
chains, children) and partition exact-ID-only against the binding index
before any CSS is emitted per spec 7.1 - never value matching.
nodeToHtml: absolute geometry at figma bounds inside a fixed-size root,
solid/linear-gradient fills, corner radius, opacity, drop shadow, blur,
text styles; resolved bindings emit var(--slug, literal), unresolved
bake literals with data-figma-unresolved; visible:false respected;
vectors/boolean ops route to a rasterize list.
hyperframes figma component: tree -> bindings -> html, rasterize
fallback via Phase-1 asset export with src backfill, registry-item
packaging, unresolved-binding guidance in output.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
tokensToVariables: variables -> composition brand-variable entries
(COLOR->hex/rgba, FLOAT/STRING/BOOLEAN), alias chains walked cycle-safe
to the leaf value while the binding keeps the semantic id. Sidecar
figma-tokens.json + .media/figma-bindings.jsonl records per spec 7.1.
hyperframes figma tokens: variables path, REQUIRES_ENTERPRISE degrades
to published-styles metadata (values resolve at component time).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
M0: renderNode/imageFills/variables/styles/nodeTree/fileVersion over
api.figma.com with injectable fetch and typed capability errors
(NO_TOKEN/BAD_TOKEN/REQUIRES_ENTERPRISE/RATE_LIMITED/RENDER_FAILED/
NODE_NOT_FOUND/HTTP_ERROR) per design spec 4.4.
M1: svg sanitizer (scripts/foreignObject/handlers/external hrefs) +
hyperframes figma asset: render -> sanitize -> freeze under .media/ ->
manifest provenance -> snippet. Idempotent on
fileKey:nodeId:format:scale:version; re-imports when the version moves.
Plus the 7.1 binding index store (.media/figma-bindings.jsonl): exact-ID
lookup incl. alias chains, per-project library-file answers, shared
jsonl reader with the asset manifest.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(core): add figma motion easing mapping
* feat(core): translate figma motion doc to gsap timeline spec
* feat(core): emit paused GSAP timeline script from figma motion spec
* fix(core): restore type exports dropped from figma barrel in Task 8
* feat(skills): add /figma import skill + catalog wiring
Add the agent-facing /figma skill (asset + Figma Motion import via the
Figma MCP connector, built on @hyperframes/core/figma) and wire it into
the skill catalog across CLAUDE.md, README.md, docs/guides/skills.mdx,
and the hyperframes router's capability map. Bumps the skill count from
19 to 20 in CLAUDE.md and README.md.
* fix(core): use replaceAll for figma node-id dash-to-colon conversion
* style: format skills catalog tables
oxfmt-align the README and router SKILL.md tables after the /figma +
/hyperframes-keyframes merge left uneven column padding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): add missing cache fields to telemetry test fixture
ExtractionPhaseBreakdown gained cachePublishFailures/cacheGcEvictions/
cacheGcBytesFreed/cacheAgedPartialsCleared; the studioRenderTelemetry
test fixture was never updated, breaking Typecheck on main and every PR
based on it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## What
Foundations of the `@hyperframes/core/figma` module — the pure, transport-agnostic layer every later phase builds on:
- **`types.ts`** — `FigmaRef`, `FigmaProvenance`, `FigmaManifestRecord`, and the Motion model (`MotionDoc`/`MotionTrack`/`TimelineSpec`/`GsapTween`) shared across the stack.
- **`parseFigmaRef`** — normalizes any user input (full `/design|/file|/proto` URLs with `?node-id=1-2`, `fileKey:nodeId` shorthand, bare `fileKey`) into `{ fileKey, nodeId }`, including the URL-dash → API-colon node-id conversion.
- **`freeze.ts`** — `freezeBytes`/`freezeUrl`/`freezeLocalFile` with a 256 MB cap; every Figma asset is frozen to a local file before it can reach a composition (determinism: no render-time network).
- **`manifest.ts`** — the `.media/manifest.jsonl` ledger (same layout `media-use` writes, so a project has one shared media inventory without either skill depending on the other): append/read/find-by-node/next-id, with a pure type-guard (`isFigmaManifestRecord`) instead of `as`-casts.
- **`assetSnippet.ts`** — manifest record → composition `<img>` snippet with escaped attrs + `data-figma-id`.
- **publishConfig fix** — `./figma` added to `packages/core` `publishConfig.exports` (the packed-manifest CI gate requires every source export to have a dist mapping).
## Why
Design spec: `docs/superpowers/specs/2026-06-30-figma-asset-integration-design.md`. These functions are deliberately transport-agnostic — when the project reversed from MCP-first to a REST/MCP split (spec §2), nothing in this layer changed. That was the point.
## Tests
Unit tests per module (URL variants, freeze cap edges, manifest round-trip/malformed-line tolerance, snippet escaping). All colocated `*.test.ts`, vitest, no network.
---
Stack (1/6): this PR → #1869 → #1870 → #1871 → #1872 → #1873🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fixes#1847
The producer's render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview.
Changes:
- Wire flattenInnerRoot into the producer's sub-composition inliner (packages/producer/src/services/htmlCompiler.ts) so its render-time DOM shape matches the preview bundler's.
- Rewrite a bare root [data-composition-id="X"] box selector to a :has()/:not() pair that lands on exactly one of the host or the flattened wrapper (packages/core/src/compiler/compositionScoping.ts), avoiding double-applying additive properties like padding.
- Restore the composition's own id onto the flattened wrapper when the host has no id of its own, an "anonymous" host (packages/core/src/compiler/inlineSubCompositions.ts).
- Fix the runtime's startResolver to find a composition's start time through the post-inlining data-composition-file marker, not just data-composition-src or data-composition-id (packages/core/src/runtime/startResolver.ts).
Also adds regression coverage for the literal issue #1847 repro (a class, not just an id, on the authored root, styled via a descendant selector), a test proving the runtime compositionLoader's anonymous-host path doesn't share this bug, and fixes stale test documentation and a misattributed code comment surfaced during review.
Verified: 29-fixture Docker regression sweep on linux/amd64 (matching CI) run 3x clean, 967/967 core unit tests, full CI green.
Renames the motion-surfacing tool from `hyperframes keyframes` to `hyperframes motion`,
renames the implementation from keyframes*.ts to motion*.ts (keeping the keyframe data
model name where still accurate), and renames the shipped skill from
hyperframes-keyframes to hyperframes-motion. Expands the skill from a command
reference into a full motion-design workflow: reading motion, 3D angle verification,
layered GSAP motion, one-shot reference reproduction, diagnostic checks, and
eval-derived craft guidance.
Users pick an --resolution preset whose orientation/aspect ratio (or alpha/HDR mode) conflicts with the composition; the render fails deep in the compiler with a cryptic message. ~8K err / ~1K users.
- New shared pure helper checkOutputResolutionCompatibility in @hyperframes/parsers — single source of truth for aspect/alpha/HDR/downsample/non-integer-scale constraints; suggests the matching-orientation, tier-preserving preset.
- CLI render pre-flight aborts early (before browser/ffmpeg) with an actionable, fix-suggesting message; resolveDeviceScaleFactor delegates to the same helper for identical defense-in-depth messages.
- Suggest (not auto-select); defers when dims can't be determined rather than guessing.
- suggestMatchingPreset keys tier off the -4k suffix so square-family swaps (square + landscape-4k -> square-4k) aren't downgraded to HD.
- render.js DOM polyfill made a lazy import; render.test cold-import beforeAll hooks given a 30s timeout to absorb CI contention.
Render-reliability workstream P1-3. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes#1838