Skip to content

fix: load a text style's font before writing it, and let its typography bind to variables - #168

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

fix: load a text style's font before writing it, and let its typography bind to variables#168
awdr74100 merged 3 commits into
mainfrom
feat/text-style-variable-bindings

Conversation

@awdr74100

Copy link
Copy Markdown
Owner

The last shape #164 exposed on read but could not write back — and, found while measuring it, a
bug that had nothing to do with bindings.

The bug found on the way

update_text_style with nothing but a fontSize fails:

in set_fontSize: Cannot write to node with unloaded font "Inter Regular".

So does lineHeight, and so does letterSpacing. Reading a style does not load its font, and
these handlers only loaded one when the call also passed fontName — so updating a size on any
style the caller had not also re-fonted in the same session simply failed. That is most styles.

create_text_style had the same hole with a worse ending: it wrote after creating, so the throw
left an orphan style behind in the design-system panel — reproduced live, style intact in the file
with the default fontSize: 12 it never got past.

Both fixed by loading the style's own face before any typography write. That load is hoisted to
just after creation, the earliest possible when fontName is omitted (the face is then whatever
default Figma put on the fresh style). Safe where the caller-input load is not: a default face
cannot fail on a bad argument.

The test that asserted the opposite went with it — it read "a size-only change touches no glyphs
→ no font work"
, which is a plausible thing to believe and not what Figma does.

Text-style bindings

create_text_style / update_text_style now take the same boundVariables get_styles reports.
A text style's values are scalars, so unlike a paint or an effect there is no per-object level to
hang them on — the style itself carries them.

Two differences from the paints and effects in #167, both because a text style write is a patch
rather than a whole-array replacement:

  • Omitting a field leaves it alone; null is how a caller unbinds — the shape set_text_range
    already uses for a run's bindings.
  • Bindings apply after literals, so a bound field wins over a literal passed for the same field
    in one call.

Binding a font field has a prerequisite the numeric ones do not — measured:

bound field needs a font loaded first?
fontSize, lineHeight, letterSpacing, paragraphSpacing, paragraphIndent no
fontFamily yesunloaded font "<family> <style>" otherwise
fontStyle, fontWeight not observed to, but same treatment

So the faces are walked as a chain in the order the bindings apply — family swap, then style swap —
and preloaded best effort. Failures there are swallowed deliberately: a guessed face may not exist
(a family from one mode paired with a style from another never co-occurs), and rejecting the write
for that would fail a binding Figma would have taken. Anything genuinely missing still surfaces,
from Figma, naming the exact face.

Ids resolve before createTextStyle(), so an id matching no variable creates nothing at all.
What that cannot cover — the target face is only computable once the style's base face is, i.e.
after creation when fontName was omitted — is undone with a remove() instead.

Measured limits

A multi-mode variable could not be tested: this account's plan caps a collection at one mode
(in addMode: Limited to 1 modes only). The preload takes every mode's value into account, so the
single-mode case is exact and the multi-mode one is covered by the best-effort pass rather than by
evidence.

Verification

  • 10 mutations of the new logic, each confirmed to fail at least one test.
  • Live, end to end: the fontSize + lineHeight + letterSpacing update that threw an hour
    earlier now succeeds; a fontFamily binding resolves the style's face to Roboto Regular with
    no unloaded font; a bound fontSize reads back as the variable's 42; null removes one
    binding and leaves its sibling; and an unresolvable id fails with
    create_text_style: variable VariableID:9999:9999 not found, creating nothing.
  • Every commit builds and passes the full suite on its own.

Measured against a live file: `update_text_style` with nothing but a fontSize
answers `in set_fontSize: Cannot write to node with unloaded font "Inter
Regular"`. So does lineHeight, and so does letterSpacing. Reading a style does
not load its font, and these handlers only loaded one when the call also passed
`fontName` (or textWrapStyle, which had already been found to need it) — so
updating a size on a style the caller had not also re-fonted simply failed,
which is most styles.

`create_text_style` had the same hole with a worse ending: it wrote after
creating, so the throw left an orphan style behind in the design-system panel,
to be published to the library from there. Reproduced, and now covered.

The load is hoisted to just after the style exists, which is the earliest it
can happen when fontName is omitted — the face is then whatever default Figma
put on the fresh style, not knowable beforehand. That is fine where the
caller-input load is not: a default face cannot fail on a bad argument.

The test that asserted the opposite went with it. It read "a size-only change
touches no glyphs → no font work", which is a plausible thing to believe and
not what Figma does.
The last shape #164 exposed on read but could not write back. A text style's
values are scalars, so unlike a paint or an effect there is no per-object level
to hang bindings on — the style itself carries them, and create_text_style /
update_text_style now take the same `boundVariables` get_styles reports.

Two differences from the paints and effects, both because a text style write is
a PATCH rather than a whole-array replacement: omitting a field leaves it as it
was instead of unbinding it, so `null` is how a caller unbinds — the shape
set_text_range already uses; and bindings apply after the literals, so a bound
field wins over a literal passed for the same field in one call.

Binding a font field has a prerequisite the numeric ones do not: Figma answers
`unloaded font "<family> <style>"` unless the face the binding resolves to is
already loaded. The faces are walked as a chain in the order the bindings are
applied — family swap, then style swap — and preloaded best effort. Failures
there are swallowed on purpose: a guessed face may not exist (a family from one
mode with a style from another never co-occurs), and rejecting the write for
that would fail a binding Figma would have taken. Anything genuinely missing
still surfaces, from Figma, naming the exact face.

Ids resolve before `createTextStyle()`, so an id matching no variable creates
nothing at all. What that cannot cover — the target FACE is only computable
once the style's base face is, i.e. after creation when fontName was omitted —
is undone with a remove instead.
Records the two ways it differs from binding a paint or an effect, both
consequences of a text style write being a patch: null unbinds, and a bound
field beats a literal sent for the same field in the same call.
@awdr74100
awdr74100 merged commit 759617d into main Aug 22, 2026
4 checks passed
@awdr74100
awdr74100 deleted the feat/text-style-variable-bindings branch August 22, 2026 18:27
awdr74100 added a commit that referenced this pull request Aug 22, 2026
… measured (#169)

#168 shipped this preload with a hole in its evidence: the account it was built
on caps a collection at one mode, so the multi-mode reasoning was argued rather
than observed, and its comment said so.

Measured now, on a two-mode collection: only the face the style actually
RESOLVES to has to be loadable. A variable whose non-default mode names a font
nobody has installed binds fine — which makes swallowing a failed preload
load-bearing rather than defensive, since letting that failure through would
reject a binding Figma accepts. Also confirmed live: a family and a style
binding compose into one face (Roboto + Bold -> Roboto Bold), which is what the
chain exists to keep loadable at each step.

Three tests pin what was previously only argued. The multi-mode one is written
so narrowing the preload to a single mode fails it — the first attempt asserted
a family that happened to be in the first mode, and a mutation that read only
that mode survived it.

The faces are keyed now too: with a family binding and no style binding the
intermediate and final face are the same, and asking twice was pointless even
against a cached loadFontAsync.
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