Review follow-ups (both reviewers, all findings):
- resize captures scope to the resize group: convert-to-keyframes
resolvedFromValues and the whole-offset backfill pass the group filter,
so an opacity-touching intro tween can't ride into a converted scale
tween (the rotation fix's contract, now uniform across intercepts)
- commitStaticSet resolves every group's target set BEFORE committing and
coalesces groups landing on the same legacy mixed set into one commit —
the second commit can no longer chase a stale group-derived id
- installAuthoredOpacityCapture also stamps an element the moment it GAINS
data-color-grading at runtime (attributeFilter), not just at insertion
- both writer twins now share the same emitted-set dedupe shape
- applySoftReload's positional tail becomes a SoftReloadOptions object
- readAllAnimatedProperties builds the group-filtered key set immutably
instead of deleting from the set mid-iteration
- applyAuthoredInlineOpacity documents the priority-lossy round-trip
- the marquee hit-test reads activeCompositionPathRef like its neighbors
New tests: resize intercept (scale route + group filter + non-uniform
longhands), after-write-HTML / stamp / empty-stamp opacity restore, the
no-op-commit-with-missed-instant-patch soft-reload contract, and the
runtime-gained-grading stamp.
The color-grading engine hides its source element with inline
'opacity: 0 !important', so any code that later reads or re-captures the
element's opacity sees the hide instead of the authored value. Stamp the
authored inline opacity on every [data-color-grading] element at document
parse time (MutationObserver installed at runtime-bundle eval, before any
composition script runs) and prefer the stamp when hiding/restoring.
Also re-sync the grading canvas when the source's inline geometry mutates
(rAF-throttled style observer): a studio drag moves the source via its
transform, which fires no media event, so the visible canvas froze in
place until the next seek.
* fix(core): escape NUL delimiters in HFMASK mask token and restore regex
Raw 0x00 bytes in the maskInertRegions token and restore regex made
timingCompiler.ts binary to git and shipped raw NULs into dist/cli.js.
Bun's transpiler (<= 1.3.11) corrupts raw NULs in regex literals into
literal backslash-uFFFD text, so restore never matched: every masked
<style>/<script> region was dropped, the player never initialized, and
bunx renders produced blank white frames showing HFMASK tokens.
Use \u0000 escapes instead, which survive any transpile layer, and add
a byte-level regression test (behavior is identical under Node, so only
a byte check catches this).
Fixes the first half of #2139.
* fix(cli): use NTFS junctions for studio project links on Windows
linkProjectIntoStudioData called symlinkSync(dir, path, "dir"), which
needs Developer Mode or elevation on Windows, so preview and dev in
local-studio mode died with EPERM for default-configured users.
Junctions need no privilege, work for directories, and keep the live
write-back the studio depends on (a copy fallback would decouple the
studio from the real project). Covers both preview and dev, which share
the helper.
Fixes the second half of #2139.
nodeToHtml routed rasterize eligibility off node.type alone, so a
RECTANGLE/FRAME with an IMAGE fill fell through to the generic <div>
path — fillCss() has no IMAGE case, so it rendered an empty box.
IMAGE-filled nodes now route to rasterize like vectors, regardless of
node.type.
Rasterized nodes (vectors, now image fills too) were also getting
their own fill/corner-radius CSS applied on top of the already-
rendered <img> — a flat color block behind/around the real art,
flattening non-rectangular shapes into rounded rects. decorationCss
now skips background and corner-radius/clip for rasterized nodes;
opacity and effects still apply since those aren't baked into the
export.
tokens.ts's styles-fallback path hardcoded entries: [] regardless of
how many published styles were actually found, so the CLI printed
"recorded published style metadata instead" even when styles()
returned zero results. Added styleCount to the result so the message
reflects what happened, and points at the MCP get_variable_defs
fallback when there's nothing to fall back to.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
CodeQL: shell command built from environment values — the oxfmt
invocation interpolated a filesystem-derived absolute path into a shell
string. execFileSync with array args avoids the shell entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rames's inline findings on #2112:
- forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws
inside) so the caller's single throw covers all cases — no mixed
throw/return contract for a future wrapping caller.
- retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer
silently blocks the CLI for an hour before RATE_LIMITED.
- asset ref gathering extracted to gatherAssetRefs() and made URL-safe:
bare fileKey:nodeId tokens comma-split, but a figma URL with commas in
its query (multi-select node-id=1:2,3:4) is kept whole.
- Documented in SKILL that 429 retry lives in the shared request path, so
EVERY read endpoint retries (not just asset) — blast-radius note the
reviewer asked for. variables intentionally still retries: its fallback
is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate.
Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs
URL-vs-bare split. client 24, cli asset 11.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the scope+retry work from the figma bug-bash (valid report:
9-bugs-with-repros; the skill-not-used report was discarded).
- 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad
PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing
scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN
with re-mint advice; a scope body surfaces figma's own diagnosis verbatim;
else falls back to the endpoint's scope hint. Reads both err and message
(variables endpoint uses message). One fix, honest messages for bugs 1/4/9.
- Batch asset fetch (requested): figma asset accepts multiple refs
(space-separated or comma-joined) of one file and renders them in a SINGLE
/v1/images call via new client.renderNodes — figma's documented per-minute
rate-limit workaround. runAssetImport delegates to runAssetImportMany;
cache-checks per node, batches only the misses, one index.md regen.
- NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling
the numbered setup list. Indent every line; single-line hints unchanged.
Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not
scope advice. Client suite 22, cli figma 33.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two bugs from live figma-integration use:
1. `tokens` styles fallback 403s on non-Enterprise. /v1/files/:key/styles
needs library_content:read — a scope the setup docs and the generic
FORBIDDEN message both omitted, so the user saw "missing a read scope"
with no way to know which. Each endpoint now carries a scope hint; the
403 names the exact scope (styles → library_content:read). Setup text and
skill scope list updated to include Library content: Read-only.
2. `asset` (and every per-node component render) had no 429 handling — the
message said "back off and retry" but the client didn't. Two imports in
a row tripped the per-minute limit and hard-failed. get() now retries 429
with exponential backoff, honoring Retry-After when present, before
surfacing RATE_LIMITED after maxRetries (default 3). sleep is injectable
so tests don't wait.
Batch multi-node asset syntax (the documented /v1/images comma-ids rate
workaround) is a separate enhancement — retry makes the reported failure
self-heal, including the many-node component path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## What
`applyPositionEdits(doc)` in `@hyperframes/core/runtime/position-edits` guarded each candidate element with `instanceof HTMLElement`. `doc` is frequently an iframe's document (the SDK's edit preview, any host embedding a composition), whose elements are `HTMLElement` instances of *that frame's realm* — never this module's. The check silently no-ops on every single element cross-realm, so bulk position edits never apply inside an iframe.
## Why
Found during an audit of `@hyperframes/sdk`'s surface against pacific's movio integration. Pacific's `canvas-react` code has an explicit workaround comment for this exact bug: *"Upstream fix would be duck-typing in `@hyperframes/core` — until then, all host code must use this wrapper."* Every iframe-hosted consumer has had to reimplement the bulk-apply loop themselves to avoid it.
## How
Use the document's own realm's `HTMLElement` constructor (`doc.defaultView?.HTMLElement`) instead of the module-scope global. Duck-type on `.style` when `defaultView` is unavailable (a detached/synthetic document). The single-element `applyPositionEditToElement` was already realm-safe — only the bulk wrapper had the bug.
## Test plan
- [x] New regression test using a real jsdom iframe — confirmed it fails on the old `instanceof HTMLElement` check (0 applied, expected 1) and passes with the fix
- [x] Full existing `positionEdits.test.ts` suite passes (14/14)
- [x] Full `@hyperframes/core` suite passes (81 files / 1131 tests)
- [x] `bun run build` clean (core + full workspace, incl. studio)
A figma text node whose box is shorter than its line-height carries
vertically-trimmed (cap-to-baseline) bounds. The mapper positioned the box
at those bounds but let the browser lay glyphs with half-leading, pushing
them ~6px low on a 70px font (glyph-centroid measurement against figma's
own render: +9.1px vs figma's +3.4px inside the same pill). Emitting
text-box-trim: trim-both / text-box-edge: cap alphabetic reproduces the
trim in the render engine; post-fix centroid agrees within 0.4px and the
motion verifier's min window score improved 20.3 -> 25.3dB. Trim applies
only to single-line trimmed text; boxes matching their line-height are
untouched.
Skill: component imports now include a static fidelity self-check step
against figma's PNG export of the same node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- motionContextToDocs: escape regex metacharacters in arrayAfterKey /
scalarAfterKey key interpolation (safe today for \\w+ keys; now safe for
any future caller), and document balancedBlock's no-strings invariant.
- verify-motion.mjs: execSync shell string -> spawnSync with array args
(JSON.stringify is not shell escaping); verifier re-calibrated unchanged
(faithful render still PASS at min 20.30dB).
- command-failure-tracking: rebase folded the group-delegation skip into
upstream's recursive wrapCommand (HF#2033) — leaf commands now assert
their own flag tables, so `figma component --namee` is rejected at the
leaf while `--name` passes the group; heuristic invariant documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two guarantees so figma-motion imports can't drift from the design again:
- motionContextToDocs(): raw get_motion_context response -> MotionDoc[],
in code. Parses the motion.dev snippets (the reliable encoding; the CSS
snippets stretch durations and can disagree), strips loop-wrap tail
keyframes (sub-ms segments at the window end are the loop reset, not
authored motion), preserves bezier eases verbatim. Fixture test uses the
verbatim response from a real Motion timeline whose translation was
frame-validated against Figma's own export_video render.
- skills/figma/scripts/verify-motion.mjs: mandatory post-render gate.
Compares motion-energy deltas between the render and the export_video
ground truth so static import fidelity cancels out and the score
isolates choreography. Calibrated on a faithful translation (min 20.3dB)
vs a diverging one (min 5.0dB); threshold 15dB.
The skill's Motion step now routes through both: no hand transcription,
no unverified completion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
slugify("3D Object - Headphones") produced id="3d-object-headphones" —
valid HTML, but querySelector("#3d-…") throws (CSS idents cannot start
with a digit), which kills GSAP targeting and figma-motion translation
against imported components. uniqueSlug now prefixes digit-leading slugs
("n3d-object-headphones"). Found translating a real Figma Motion timeline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both found running the brand-loop guide end-to-end against the Simple
Design System:
- nodeToHtml subtracted the ROOT origin from every node's absolute bounds,
but CSS absolute positioning resolves against the nearest positioned
ancestor — every nesting level re-added its ancestors' offsets, drifting
nested content down-right and pushing deep children off-frame (hero
buttons invisible, pricing grid collapsed to one card). Children now
subtract their PARENT's box; regression test with a two-level tree.
- trackCommandFailures asserted unknown flags against the command group's
own (flagless) arg table even when the group was delegating to a
subcommand, so `figma component <ref> --name x` imported and THEN threw
"Unknown flag: --name". The assertion is now skipped when the first
positional names a subcommand; leaf and non-delegating behavior is
unchanged and covered by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
applyPositionEdits(doc) guarded each element with `instanceof HTMLElement` —
but `doc` is frequently an iframe's document (the SDK's edit preview, any host
embedding a composition), whose elements are HTMLElement instances of THAT
frame's realm, never this module's. The check silently no-ops on every single
element cross-realm, so bulk position edits never apply inside an iframe.
Use the document's own realm's HTMLElement constructor (doc.defaultView);
duck-type on `.style` when defaultView is unavailable (a detached/synthetic
document). The single-element applyPositionEditToElement was already
realm-safe — only the bulk wrapper had the bug.
Added a regression test using a real jsdom iframe, confirmed it fails on the
old `instanceof HTMLElement` check and passes with the fix.
Closes gaps surfaced by pacific#30298 (hyperframes layer panel), where consumer
code had to hand-roll fixes for things the SDK/core already solve or nearly solve:
- getRootElements(): getElements() flattens the tree, so every descendant also
appears as its own top-level entry. buildRoots() already computes true roots
internally; this exposes it directly instead of making consumers re-derive
roots by filtering out descendant ids.
- Export isNewHostBoundary + bareId from @hyperframes/sdk: both already existed
internally (engine/model.ts) but weren't exported, so consumers were
duplicating sub-composition-boundary detection and scoped-id-to-DOM-leaf
conversion by hand.
- Export stripEmbeddedRuntimeScripts + RUNTIME_BOOTSTRAP_ATTR from
@hyperframes/core, and wire serialize({ stripRuntime: true }) on the SDK
session: a proper tokenizing implementation already existed in
compiler/htmlDocument.ts (handles more runtime-script marker variants than a
naive regex), just never exported. The SDK itself imports these via narrow
subpaths (./runtime/start-expression, ./compiler/html-document) rather than
the wide ./compiler barrel, matching the SDK's existing import convention and
avoiding pulling Node-only compiler code (fs/path) into browser bundles.
- Fix getElementTimings(): data-start can be a relative-reference expression
("intro", "intro + 2" — see parseStartExpression's grammar), not just an
absolute number. The old code did a raw parseFloat() on it, which silently
resolved any reference expression to 0. Now resolves references recursively
against the target element's own resolved start + duration, Node-safe (no
live GSAP timeline needed for this case).
14 new tests (session.timings.test.ts, session.subcomp.test.ts). Full sdk
suite: 417/417 passing. Full workspace build (incl. studio) verified clean.
Extending a clip past the video end used to force the server-fallback
path that fully remounts the preview iframe (the SDK fast path can't
express the root composition's data-duration, and the runtime bakes+drops
data-duration at load so it can't be patched live). On a large comp that
remount is a visible hitch.
Add a runtime control-bridge action set-root-duration -> clock.setDuration,
so the studio can grow the transport length in place. On an extend the
studio now posts it (and patches the clip's own timing live) instead of
reloading; it only reloads when a GSAP source rewrite actually happened
(the gsap-mutation endpoints now report a mutated flag). Non-animated
extends — the common case — commit as fast as a normal edit.
Verified: bridge dispatch + studio no-reload/post-message paths unit-
tested; core/studio/studio-server typecheck + suites green; the built
runtime artifact carries the handler; E2E confirms the extend no longer
remounts the preview and still persists.
Sub-composition <head> styles targeting html/body/:root (width/height/
overflow/background) were injected into the parent document unscoped by both
the Studio runtime mount (compositionLoader) and the render-time inliner
(inlineSubCompositions/htmlBundler). scopeCssToComposition deliberately passed
html/body/:root through unchanged, so a sub-composition smaller than the root
clobbered the host <body> dimensions and its overflow:hidden clipped the
composite to the last sub-comp's size. Only the top-left element painted;
everything else (and framework-owned video positioned outside that box) was
clipped away.
Add a scopeRootSelectors option to scopeCssToComposition that remaps
html/body/:root to the composition's own box, and enable it everywhere
sub-composition styles are scoped. The universal selector stays untouched.
Top-level composition scoping is unchanged (it legitimately owns the document).
Covered by new compositionScoping tests.