feat: carry variable bindings through the write path - #167
Merged
Conversation
…ies on write #164 made these bindings readable; without the other half, reading a style, editing it and writing it back replaced the token reference with the frozen literal sitting next to it, and the style silently stopped tracking its variable. Every tool that takes a paint / effect / layout grid now carries the binding through: set_fills, set_strokes, set_effects, set_layout_grids, set_text_range's per-run fills, and the create_* / update_* style pair. Two measured facts decided the shape. Figma accepts a binding embedded in a plain object literal — the only way to bind a gradient stop, which has no setter — but that path validates almost nothing: an id matching no variable, and a variable of the wrong resolved type, are both taken without complaint and then render white. So bindings go through the official `setBoundVariableFor*` setters wherever one exists, which reject both, and the gradient stop is checked here instead. Every id resolves before anything is assigned, so a stale binding throws with the id named instead of half-writing a node — or, for the create_* pair, instead of leaving a wrecked style behind in the design-system panel to be published to the library from there. Unbinding needs no separate path: Figma replaces the whole array, so a paint or effect written back without its `boundVariables` clears what it was bound to. That is now the documented contract rather than an accident.
…all three
The paint and effect input schemas were already loose, so a `boundVariables`
read out of Figma reached the plugin even undeclared — but nothing said it was
allowed, and the nested gradient-stop object was strict, so a bound stop was
stripped before it ever left the server. The layout grid was worse on both
counts: strict, and carried inline in two tools that had already drifted apart
in their descriptions. Extracted to grid-schema.ts alongside the paint and
effect ones it should have matched from the start.
Declaring the field also puts it in front of the model: a binding a caller
cannot see in the schema is a binding it will not send, which would have left
the write path above with nothing to apply.
The recorded plugin contract moves by 15 arguments, all of the silent class —
an older plugin drops them and still answers { ok: true }. MIN_PLUGIN_VERSION
already stands at 0.5.0, the release these ship in, so the floor is unchanged.
The rule this replaces was written when the write path could not carry a binding, so its advice was "don't overwrite one" — change the variable instead. Now that a binding round-trips, the useful instruction is the positive one: a style and a variable compose, and that is how a shadow tracks a colour token or a grid tracks a spacing one.
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the half #164 deliberately left out. Reading a style, editing it and writing it back
still replaced the token reference with the frozen literal beside it, so the style silently
stopped tracking its variable — the exact failure the read fix made visible.
What now round-trips
set_fills·set_strokes·set_effects·set_layout_grids·set_text_range(per-runfills) ·
create_paint_style/update_paint_style·create_effect_style/update_effect_style·create_grid_style— each takes the sameboundVariables(
{ field: variableId }) the read side reports.Two measured facts decided the shape
Probed against a live file rather than inferred:
boundVariablessetBoundVariableForPainttakes aSolidPaintcan only bind color variables to color)So pass-through alone was not acceptable: Figma will quietly store a broken binding. Bindings go
through the official
setBoundVariableFor*setters wherever one exists, which reject both badcases; the gradient stop, which has no setter, is type-checked here instead. Every id resolves
before anything is assigned, so a stale binding throws with the id named rather than
half-writing a node — or, for
create_*, leaving a wrecked style behind in the design-systempanel to be published to the library from there.
Unbinding needs no separate path: Figma replaces the whole array, so a paint or effect written
back without its
boundVariablesclears what it was bound to. That is now stated in the tooldescriptions rather than left to be discovered.
Also fixed on the way
The gradient-stop object inside the paint schema was strict, so a bound stop was stripped
server-side before it ever reached the plugin. The layout grid was strict too, and carried inline
in two tools whose copies had already drifted apart — extracted to
grid-schema.tsalongside thepaint and effect ones it should have matched from the start.
Verification
first pass — no handler-level test asserted that
set_fills,update_paint_styleorupdate_effect_styleactually applied bindings, and three others were caught only incidentally.Tests added; all 19 now caught.
and via
create_paint_style; a shadow withcolorandradiusbound viacreate_effect_style(read back asradius: 40, the variable's value, not the4sent); agradient stop bound while its sibling stayed unbound; a layout grid's
gutterSize;update_paint_styleclearing the binding when omitted and restoring it when re-sent; and anunresolvable id failing with
create_paint_style: variable VariableID:9999:9999 not foundwhilecreating nothing.
MIN_PLUGIN_VERSIONalready stands at 0.5.0, the release these ship in.