Skip to content

feat: carry variable bindings through the write path - #167

Merged
awdr74100 merged 3 commits into
mainfrom
feat/write-variable-bindings
Aug 22, 2026
Merged

feat: carry variable bindings through the write path#167
awdr74100 merged 3 commits into
mainfrom
feat/write-variable-bindings

Conversation

@awdr74100

Copy link
Copy Markdown
Owner

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-run
fills) · create_paint_style / update_paint_style · create_effect_style /
update_effect_style · create_grid_style — each takes the same boundVariables
({ field: variableId }) the read side reports.

Two measured facts decided the shape

Probed against a live file rather than inferred:

what was tried result
assigning a paint / effect / grid literal carrying boundVariables accepted, and the value really is driven by the variable
the same for a gradient stop accepted — and it is the only route, setBoundVariableForPaint takes a SolidPaint
official setter, wrong-typed variable throws (can only bind color variables to color)
literal, wrong-typed variable 🔴 accepted silently, renders white
literal, id matching no variable 🔴 accepted silently, renders white
literal, non-bindable field name Figma rejects it

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 bad
cases; 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-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 stated in the tool
descriptions 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.ts alongside the
paint and effect ones it should have matched from the start.

Verification

  • 19 mutations of the new logic, each confirmed to fail at least one test. Three survived the
    first pass — no handler-level test asserted that set_fills, update_paint_style or
    update_effect_style actually applied bindings, and three others were caught only incidentally.
    Tests added; all 19 now caught.
  • Live, end to end, on a real file (built plugin, real relay): a bound solid paint on a node
    and via create_paint_style; a shadow with color and radius bound via
    create_effect_style (read back as radius: 40, the variable's value, not the 4 sent); a
    gradient stop bound while its sibling stayed unbound; a layout grid's gutterSize;
    update_paint_style clearing the binding when omitted and restoring it when re-sent; and an
    unresolvable id failing with create_paint_style: variable VariableID:9999:9999 not found while
    creating nothing.
  • The recorded plugin contract moves by 15 arguments, all of the silent class.
    MIN_PLUGIN_VERSION already stands at 0.5.0, the release these ship in.
  • Every commit builds and passes the full suite on its own.

…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.
@awdr74100
awdr74100 merged commit 0de6086 into main Aug 22, 2026
4 checks passed
@awdr74100
awdr74100 deleted the feat/write-variable-bindings branch August 22, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant