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>
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>
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>
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>
* 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)