feat(core,cli): figma tokens import with alias-aware binding records (M2)#1871
feat(core,cli): figma tokens import with alias-aware binding records (M2)#1871vanceingalls wants to merge 1 commit into
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
3e7ad7d to
e78461f
Compare
b297975 to
ed3f672
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Review — figma tokens import with alias-aware binding records (M2)
Solid PR. The translator is clean, the alias resolution is cycle-safe, the binding index records are well-structured, and the enterprise fallback path is correctly scoped. Tests cover the important cases. A few observations:
Findings
1. firstModeValue ignores defaultModeId from the collection (minor)
tokensToVariables.ts:85 — firstModeValue grabs whichever mode Object.values yields first, which relies on insertion order. The variableCollections payload carries defaultModeId — the correct "which mode is the base" signal — but the translator never consults it. For single-mode files this is fine (the test fixtures all use one mode), but multi-mode files (Light/Dark) will silently resolve to whichever mode V8's property order delivers. Consider passing defaultModeId through and resolving that mode explicitly. Not blocking since the spec says "first mode" is the MVP behavior for Phase 2 and multi-mode comes later, but worth a comment.
2. Duplicate isRecord helper (nit)
tokensToVariables.ts:56 defines isRecord — identical to bindings.ts:43. Both are private, so no runtime cost, but it's the third copy in packages/core/src/figma/. A shared util would keep it DRY.
3. Sidecar written twice on enterprise fallback (nit)
In tokens.ts:47-48, the variables path writes the sidecar inside the try block, then the styles fallback writes it again. If the variables call succeeds but the sidecar write fails (disk error), the catch rethrows correctly — no issue. But the asymmetry means a hypothetical future "append" mode would need to reason about both write sites. Fine for now; just noting the split.
4. styles() response shape — node_id vs key fallback (observation)
tokens.ts:59 uses s.node_id ?? s.key as the figmaId for style sidecar entries. The Figma REST API's published styles endpoint returns node_id as optional — some styles genuinely lack it. Using key as fallback is reasonable, but the binding index's figmaId field is spec'd as "the figma variable/style id as it appears in node data (exact match key)." A key there won't match what Phase 3's component import sees in node property bindings (boundVariables use the variable id, not the key). Since the styles path emits no binding records (entries is []), this doesn't affect resolution — but if that ever changes, the mismatch would bite. Worth a comment to lock it down.
5. Test fixtures don't exercise variableCollectionId paths (observation)
The VARS fixture in tokensToVariables.test.ts includes variableCollectionId: "c1" and a variableCollections map with defaultModeId, but the translator doesn't use either. The test fixtures are accurate for the current implementation — just noting the unused data as a breadcrumb for when multi-mode lands.
What I like
- Alias resolution is exactly right. Walking to the leaf value for the CSS default while recording the semantic bind-point on the binding record — this is the design that makes brand-refresh propagation work without re-import. The cycle detection via
chain.includes(currentId)is simple and correct for DAG-shaped alias graphs. - Clean separation of concerns. Pure translator function, DI-injectable client in the CLI command, binding records as JSONL append — each piece is testable in isolation and the CLI test proves the integration works.
- Error boundary is tight. Only
REQUIRES_ENTERPRISEtriggers fallback; everything else propagates. The test forRATE_LIMITEDpropagation is a good contract test.
Ponytail
The code is already lean for what it does. The isRecord dedup would save ~5 lines across the figma module. net: -5 lines possible — not worth blocking on.
Review by Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at ed3f672116 (stack member 4/6; sibling PRs #1868-#1870, #1872-#1873).
Note: body has 🤖 Claude Code footer + AI-trailer; HF convention flags this.
Summary — Adds tokensToVariables (pure Figma variables → composition brand-variable entries + sidecar + binding records) and hyperframes figma tokens <fileKey> CLI with Enterprise-gated variables path and styles-metadata fallback. Alias resolution is cycle-safe. Concerns are around name-collision in composition IDs, multi-mode value selection, and idempotency of the binding index.
Concerns
-
🔴
compositionVariableId = "figma:" + payload.name— silent collision across collections.tokensToVariables.ts:121. Figma allows the same variable name in different collections (a "Semantic" collection withBlue/500and a "Primitive" collection withBlue/500is a common brand pattern). Two variables with differentfigmaIds produce the samecompositionVariableId, thenentries[]gets two rows with duplicateid, and the laterbindings[]row overwrites the runtime semantics of the earlier one. Design spec §7.1 rule 4 (alias binding survives primitive swaps) relies on the composition id being 1:1 with the semantic binding — a collision breaks that guarantee. Suggest namespacing by collection:figma:<collectionName>/<name>orfigma:<figmaId>(loses human readability but guaranteed unique) or dedupe withMap<name, existing>and warn. -
🔴 Rerunning
hyperframes figma tokensappends duplicates to.media/figma-bindings.jsonl.tokens.ts:45—for (const b of out.bindings) appendBinding(deps.projectDir, b)— no dedup, no truncate. Second run doubles the file, third triples.findBindingByFigmaIdinbindings.ts:89-93returns the FIRST match (line iteration order = insertion order), so the stale record from the first run wins on subsequent lookups. For the brand-refresh workflow this PR exists to enable ("swap the primitive underneath doesn't orphan the link"), the stated goal fails on re-import. Suggest either (a) truncate.media/figma-bindings.jsonlat the top ofrunTokensImportbefore writing (rebuild-from-scratch semantics — matches sidecar'swriteFileSync), or (b) upsert byfigmaIdinappendBinding. -
🟠 Multi-mode fidelity:
firstModeValuereturns the firstObject.valuesentry, ignoringdefaultModeId.tokensToVariables.ts:73-77.FigmaVariablesResult.variableCollections[collectionId].defaultModeIdis the correct authority for "what value should the composition use" — the test attokensToVariables.test.ts:33even sets it, but the code never reads it. For a single-mode collection this happens to work; for light/dark or any multi-mode setup, the value picked depends on JS property-iteration order (insertion order in V8) — soDarkbeforeLightin the REST payload silently gives the composition the dark hex as its default. Fix path: narrowvariableCollectionsinclient.tsto exposedefaultModeId, and havefirstModeValueprefervaluesByMode[defaultModeId]with the current behavior as fallback. -
🟠
toEntryValueacceptsstringforFLOAT.tokensToVariables.ts:100-107. Ifpayload.resolvedType === "FLOAT"but the resolved value comes through as"8"(Figma occasionally stringifies), the function returns the string and the entry gets{ type: "number", default: "8" }— CompositionVariableEntry contract is broken silently. Tighten:if (resolvedType === "FLOAT") return typeof raw === "number" ? raw : null;and similar for BOOLEAN/STRING. -
🟠 CLI
tryblock swallows more than the client call.tokens.ts:42-50— thetrywrapsclient.variables(...)ANDtokensToVariables(...)ANDappendBinding(...)ANDwriteFileSync(...). If any of those throw for reasons OTHER thanREQUIRES_ENTERPRISEonvariables(), control falls through to the styles fallback, which then makes a second network call and writes a different sidecar over the (possibly partial) one from the first attempt. Scope the try todeps.client.variables(fileKey)only; move the translator + writes outside.
Nits
- 🟡
sidecar.tokensincludes unresolvable variables withvalue: null.tokensToVariables.ts:123-129, pushed before theif (!entryType || value === null || !resolved) continue. Likely intentional (designer visibility into what didn't map), but not obvious from reading; a one-line comment above the push would help future readers understand the invariant divergence between sidecar and entries. - 🟡 Cycle detection
chain.includes(currentId)is O(n²) per chain.tokensToVariables.ts:86. Token counts are tiny (~hundreds), fine — but aSet<string>would be cheap and future-proof, especially for larger design systems. - 🟡 Binding
aliasChainincludesfigmaIdas its first element.tokensToVariables.ts:144gates onresolved.chain.length > 1, so for a directly-resolved variable the field is omitted (good). For an alias, though,aliasChain[0] === figmaId;bindings.ts:91then doesb.aliasChain?.includes(figmaId)which will match a binding via its ownfigmaId— redundant with theb.figmaId === figmaIdcheck on the line above, harmless. - 🟡
Blue/500inid: "figma:Blue/500"embeds a/in an identifier. Not invalid, but any downstream consumer that treats these ids as HTTP paths or CSS classes will trip. If the runtime'sgetVariables()returns them as raw ids there's no risk — worth a docstring note.
Questions
- ↩️ Design spec §7.1 rule 4: "the binding keeps the semantic id the designer bound — swapping the primitive underneath doesn't orphan the link." I read the code as recording the original
figmaId(which is the semantic-bound id) with the alias chain intact, and always resolving to the leaf for the runtime value. That's correct as long as re-imports respect the existing binding when the primitive value changed but the semantic id didn't — which loops back to the duplicate-append concern above. Is the intended flow "always re-import from scratch" or "upsert / diff against existing bindings"? - ↩️ Styles-fallback sidecar has
value: nullfor every entry (tokens.ts:61), on the grounds that "values resolve at component-import time (Phase 3)". Where does that resolution actually happen for astyle:FILL— is Phase 3 (#1872 component) going to write over this sidecar with resolved values, or is the composition's runtime expected to defer to some Figma-side lookup? - ↩️
payload.nameis used as bothlabeland part of theid. Figma variable names contain/for grouping (e.g.Blue/500), spaces, unicode.entries[].idandlabelboth get the raw name. Is that intentional for both, or shouldlabelbe human-readable (Blue/500) andidbe a slugified form?
What I didn't verify
- Did not read #1868 (foundations) client shape end-to-end for
FigmaVariablePayloadcompleteness — took the types inclient.tsat their word. - Did not verify the actual
data-composition-variablesruntime contract;CompositionVariableEntryshape is asserted here but I didn't cross-read the studio/runtime side to confirmtypevalues line up with the getVariables consumer. - Did not run the tokens command against a real Figma file or verify that a REQUIRES_ENTERPRISE 403 is the exact response Figma returns for the variables endpoint on non-Enterprise plans.
- Did not read #1872 (component import) to see how the binding records are actually consumed — the "makes brand refresh propagate" claim is unverified here.
— Rames D Jusso
…(M2) 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>
e78461f to
d707f15
Compare
ed3f672 to
1b7a1de
Compare
|
Review feedback addressed (pushed in the absorbed update): Fixed — both 🔴s
Answers
🤖 Generated with Claude Code |

What
M2: Phase-2 tokens import with alias-aware binding records.
tokensToVariables.ts— pure translator: Figma variables payload → composition brand-variable entries (COLOR→hex/rgba(),FLOAT/STRING/BOOLEAN), a human-readablefigma-tokens.jsonsidecar, and binding-index records. Alias chains are walked cycle-safe to the leaf value, but the binding keeps the semantic id the designer bound — swapping the primitive underneath doesn't orphan the link (spec §7.1 rule 4). Alias cycles skip the variable instead of hanging.hyperframes figma tokens <fileKey>— variables path writes entries + sidecar +.media/figma-bindings.jsonl;REQUIRES_ENTERPRISE(variables are Enterprise-gated upstream) degrades cleanly to published-styles metadata (style values resolve at component-import time, Phase 3); any other failure propagates.Why the binding records matter
They're the join that lets #1872's component import emit
var(--brand-role, #literal)instead of duplicating hexes — the thing that makes a later brand refresh propagate through imported components with zero re-touch. Design: spec §7.1 (tokens-before-components, exact-ID matching only).Tests
Translator fixtures (hex/rgba conversion, alias chain + recorded chain, cycle survival, provenance stamping, sidecar completeness) + CLI tests for both paths and error propagation.
Stack (4/6): #1868 → #1869 → #1870 → this PR → #1872 → #1873
🤖 Generated with Claude Code