Skip to content

feat(figma-sync): generate Figma component library from Storybook - #2140

Draft
ggazzo wants to merge 7 commits into
mainfrom
claude/storybook-figma-publish-6c0509
Draft

feat(figma-sync): generate Figma component library from Storybook#2140
ggazzo wants to merge 7 commits into
mainfrom
claude/storybook-figma-publish-6c0509

Conversation

@ggazzo

@ggazzo ggazzo commented Aug 4, 2026

Copy link
Copy Markdown
Member

Generates Fuselage's Figma component library from Storybook, as a replacement for story.to.design that understands our token architecture.

Draft — the workflow added here has never run on a runner. Opening as draft so CI exercises it.

Why build this instead of buying it

getComputedStyle resolves var() away and leaves only a hex, so any generic DOM-to-Figma converter has to guess which variable to bind — and Fuselage has 11 pairs of tokens that share a value. Reading the authored declaration out of the CSSOM instead exposes the whole chain:

var(--rcx-button-primary-background-color,             /* override hook, never declared */
  var(--rcx-color-button-background-primary-default,   /* component token, DECLARED     */
    var(--rcx-color-blue-500, #156FF5)))               /* primitive, never declared     */

Walking it to the first declared custom property yields the binding the code actually means. 44% of bindings resolve by exact token name; the rest fall back to matching a variable by value, which the extractor reports.

Two things this surfaced about our own tokens, worth a look independently of this PR:

  • Primitives are compile-time only — --rcx-color-blue-500 does not exist at runtime.
  • Button's warning, secondary-warning and secondary-success have no button-* custom property, and border-width / border-radius have none anywhere.

What's in scope

10 components / 98 variants: Button, Tag, Badge, Callout, Banner, Chip, Label, FramedIcon, Divider, Tooltip.

Twelve more were tried and rejected rather than shipped wrong — each recorded in the skipped array in components.json with its reason. They cluster into three gaps: child-node measurement (ProgressBar, CheckBox, RadioButton, ToggleSwitch, CodeSnippet), gradient/shadow paints (Skeleton, Tile), and SVG vectors (StatusBullet).

How it stays deterministic

The rule is that AI decides once, config records it, and the pipeline replays it. Picking a rootSelector or deciding which args are axes is a reasonable thing to hand to a model; the answer is then frozen into components.json and reviewed here, and nothing in the hot path decides anything.

Guarantee Enforced by Fails how
Config well-formed, skips justified yarn workspace @rocket.chat/figma-sync check non-zero exit
plugin/code.js matches src/apply.js same regenerates, then fails
apply.js keeps its invariants same greps code, not comments
Spec shape valid validateSpec in extract.mjs exit 1, names the path
A component measured wrong error-level finding exit 1
A measured value changed committed snapshot diff exit 2, prints the diff
Re-applying is a no-op emit-apply.mjs --twice idempotent: false
Reproducible across machines Chromium pinned via the lockfile

figma-spec.snapshot.json is committed, so any change to a measured geometry or token binding lands as a reviewable diff here rather than propagating quietly.

Applying the spec is not automated, and cannot be

CI generates and publishes the spec; a human applies it. The blocker is authentication, not headlessness — Figma's remote MCP server does write to the canvas server-side with no desktop app and no open editor, but it is OAuth-only ("does not support authentication using personal access tokens and this cannot be enabled") and enforces a client allowlist a GitHub Action cannot satisfy. The REST API cannot create or modify nodes at all.

The one part that could be fully automated is the token collections, via POST /v1/files/:key/variables with a plan access token — but that endpoint needs an Enterprise plan and we are on Organization.

Open question for review

plugin/ may be dead weight. Because the MCP path is headless, an engineer can apply the spec without it. The plugin only earns its place if we want designers to trigger a sync. Happy to drop it from this PR.

Next steps

Ordered by what blocks what. Nothing here belongs in this pull request — it is recorded so the follow-up work is not rediscovered later.

1. Move the Figma file out of drafts, then publish it. This is the actual blocker: the 15 component sets and 187 variables exist, but a draft cannot be published as a library, so nobody can consume them. Pick the destination before publishing — moving a file between teams after publishing breaks the library link in every file that consumed it.

2. Publish it as [WIP] Fuselage Sync. We are on the Organization plan, where a published library appears in every member's library picker; scoping a library to one team is an Enterprise feature. Publishing never enables anything, so no existing file changes and a component only appears for someone who turns the library on by hand — the name is what stops accidental adoption. The convention already exists in this organization next to [WIP] Design System - React Native App and Icon lib test demo [delete me].

3. Code Connect. Everything in this pull request pushes code → design. Code Connect points a Figma component back at its source, so a designer inspecting a variant sees the real <Button variant="primary">. It publishes over the REST API with a plain token — no OAuth, no client allowlist — which makes it the one remaining piece that CI can own end to end.

4. Decide what happens to the Skeleton page in Figma. Its three variants shipped invisible before the wrapper/gradient check existed and the component is now in skipped, but the page is still in the file with nothing driving it.

5. Measure a Tamagui component before the SCSS files are removed. The extractor reads the rendered DOM, not .scss, so removing SCSS is not automatically a problem — but that only holds if a Tamagui build still emits a stable root selector and a var() chain that reaches a declared custom property. Measuring one Button on the Tamagui branch decides whether this costs one function or a rethink.

6. Phase B gaps, already listed above: child-node measurement (5 components), gradient and shadow (2), SVG and transforms (2).

Not on this list: re-running the extractor by hand to check whether Figma has drifted. CI now comments on the pull request when the spec stops matching the committed snapshot, with the re-sync commands ready — that gap is what let the library fall a component behind.

Review notes

  • plugin/code.js is generated from src/apply.js — don't review it as hand-written, and don't edit it.
  • yarn.lock changed only to register the new workspace; without it yarn install --immutable fails in CI.
  • figma-spec.json is gitignored; it's a build artifact. The committed snapshot is the reviewable digest.

ggazzo added 2 commits August 4, 2026 13:29
Adds `tools/figma-sync`, an in-house replacement for story.to.design that
understands Fuselage's token architecture.

A Playwright extractor drives each story through its `argTypes` matrix, measures
the rendered component, and emits `figma-spec.json`. Applying that spec is a
separate step. All the logic lives in the extractor; the apply step makes no
design decisions.

The reason to build this rather than buy it is token binding. `getComputedStyle`
resolves `var()` away and leaves only a hex, so a generic DOM-to-Figma converter
has to guess which variable to bind — and Fuselage has 11 pairs of tokens that
share a value. Reading the authored declaration out of the CSSOM instead exposes
the whole chain:

    var(--rcx-button-primary-background-color,             <- override hook, never declared
      var(--rcx-color-button-background-primary-default,   <- component token, DECLARED
        var(--rcx-color-blue-500, #156FF5)))               <- primitive, never declared

Walking it to the first *declared* custom property yields the binding the code
actually means. 44% of bindings resolve by exact token name; the rest fall back
to matching a variable by value, which the extractor reports as a warning.

Phase A covers 11 box-shaped atoms / 101 variants: Button, Tag, Badge, Callout,
Banner, Chip, Label, FramedIcon, Skeleton, Divider, Tooltip.

Eleven more components were tried and rejected rather than shipped wrong; each
is recorded in the `skipped` array in components.json with its reason. They
cluster into three gaps: child-node measurement (ProgressBar, CheckBox,
RadioButton, ToggleSwitch, CodeSnippet), shadow extraction (Tile), and SVG
vectors (StatusBullet). The extractor now fails loudly when every variant of a
component measures identically, which is what that class of failure looks like.

Applying the spec has to be triggered by a human, so CI only generates and
publishes it. The blocker is authentication rather than headlessness: Figma's
remote MCP server does write to the canvas server-side with no desktop app and
no open editor, but it is OAuth-only ("does not support authentication using
personal access tokens and this cannot be enabled") and enforces a client
allowlist that a GitHub Action cannot satisfy. The REST API is no help either —
it cannot create or modify nodes at all. `plugin/` is therefore only needed so
non-engineers can trigger a sync; engineers can apply the spec through the MCP
server directly, which is how the current 101 variants were written.
Every bug that survived review in this tool's first version was one with no
executable check behind it. Three of them shipped wrong output while passing an
audit that asserted the wrong invariant. This adds the checks, and the structure
that keeps them honest.

The design rule is that AI decides once, config records it, and the pipeline
replays it. Choosing a rootSelector or deciding which args are axes is a good use
of a model; the answer is then frozen into components.json and reviewed, and
nothing in the hot path decides anything.

Single source of truth for apply
  The script that actually wrote the current 98 variants was inline in MCP calls
  and therefore neither reviewable nor reproducible, while plugin/code.js held a
  380-line near-copy that had never run. src/apply.js is now the only
  implementation; plugin/code.js is generated from it by src/emit-apply.mjs, and
  a check fails when it goes stale.

Idempotency
  applySpec reads before it writes and counts real changes, so applying the same
  spec twice must report changes: 0 the second time. `emit-apply.mjs --twice`
  emits proof scripts; Tooltip returns idempotent: true with a zero first pass,
  meaning the file has converged. This is the contract that catches code folding
  current state into the target, which is exactly how a variant lost the ability
  to shrink on a re-sync.

Severity
  Findings are now error or info. `error` means the component measured wrong and
  must not ship, and exits non-zero; `info` covers the expected fall back to
  value matching. Previously both went into one flat list, which is why a
  component with five identical variants needed a human to notice.

  The new checks earned their place immediately: Skeleton, already shipped, was
  caught rendering blank. Its shimmer is an animated linear-gradient, so
  background-color reads transparent. Moved to skipped with that reason.

Snapshot
  figma-spec.snapshot.json is a normalised digest, committed. Any change to a
  measured geometry or token binding fails extraction with a line diff, so it
  lands as a reviewable change instead of propagating quietly — the failure mode
  that let a three-year-old token set look current.

Shape validation
  validateSpec asserts the spec's structure before it can reach the apply step,
  including that variant properties are capitalised, since lower-case keys create
  a duplicate component set rather than updating in place. Written as executable
  assertions rather than a .schema.json on purpose: a second file describing the
  same shape is a second thing that can drift.
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 6ea0b96

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Comment thread tools/figma-sync/src/extract.mjs Fixed
ggazzo added 2 commits August 4, 2026 15:19
The measurement generalises to any box-shaped atom; the configuration does not.
Every component needs a components.json entry, so a component added to Fuselage
did not appear here and nothing said so — 23 of the 45 components with their own
stylesheet and stories were in neither list, indistinguishable from ones we had
deliberately declined.

Three buckets now, and a check that every candidate lands in exactly one:
components (shipped), skipped (tried, rejected, reason recorded), outOfScope (a
composite or layout container we will not attempt, reason recorded). Adding a
component therefore forces a decision instead of defaulting to invisible.

src/add-component.mjs does the mechanical part: resolves the story id, reads the
root class out of the component's .styles.scss, and proposes axes from argTypes
minus a denylist of polymorphism and content args. It deliberately does not
decide which axes are visual, how to group mutually exclusive booleans, or what
sample args a component needs to render — getting those wrong ships a wrong
library, so they stay a reviewed diff.

Ran that path on the four plausible atoms among the untriaged, and let the
error-level checks arbitrate rather than eyeballing the output:

  InputBox  measures cleanly, kept — 11 components / 102 variants now
  Throbber  rejected: the dots are children with their own background-color and
            a bounce animation, so the root is a transparent flex wrapper and all
            four variants measure identically
  Chevron   rejected: direction is transform: rotate(), which the extractor does
            not measure, so 5 variants render as 2
  Icon      out of scope: a single glyph with no visual axes

The remaining 19 are composites or layout containers, each recorded with why a
single measured root cannot describe it.

Also stops prettier and the generated-file check from fighting: plugin/code.js
and figma-spec.json are both generated, and are now in .prettierignore. Before
this, formatting the plugin made the freshness check fail, and satisfying the
check made the format check fail.
Seven scripts with overlapping jobs is the reason "which one do I run, and what do
I have to maintain" was not answerable from the README.

probe-axes.mjs is deleted. add-component.mjs does what it did and more — it also
derives the root selector and proposes a whole entry — so keeping both left two
ways to start the same task with no stated difference. Nothing referenced it.

The README now opens with the answer rather than the architecture: every script is
generic and never edited, the only per-component artefact is one components.json
entry, and CI refuses to pass until a new component has one. Added a table of
which script needs what and when you run it, including the two that are libraries
and never run directly, since measure.js and apply.js do the real work but are
injected rather than executed.

Also documents the three gaps that decide whether a new component is acceptable —
child nodes, non-solid paints, SVG and transforms — with which component hit each,
so a rejection is recognisable instead of mysterious.

@julio-rocketchat julio-rocketchat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a reminder to pin the actions versions as hashes (Layne pointed it out). Thank you 🙏🏻

ggazzo added 2 commits August 4, 2026 18:02
… with a broken font

Two failures, one found by CI and one found while verifying the fix.

Build Storybook through Turbo
  The workflow called `yarn workspace @rocket.chat/fuselage build-storybook`
  directly, which skips the `build-storybook: dependsOn ["^build"]` graph in
  turbo.json, so no workspace dependency was ever compiled. It died on
  packages/storybook-dark-mode/preset.js requiring './dist/esm/preset/manager'.
  The repo's own ci-pr.yml has never hit this because it goes through
  `yarn turbo run`. Verified locally from a cold start with dist/ and
  storybook-static wiped: 10 tasks, all successful.

  This is the same error I hit locally earlier and fixed by building that one
  package by hand, without carrying the lesson into the workflow.

Refuse to measure when a web font failed
  Verifying the build fix surfaced something worse: --static and --url disagreed.
  FramedIcon measured 28x28 from the built Storybook and 22x28 from the dev
  server. The dev server 404s the RocketChat icon font, so the glyph fell back to
  different metrics — and 22 is a completely plausible width, which is why every
  measurement I took this session via --url was quietly wrong for that component.

  `document.fonts.status === 'loaded'` does not catch it: it means "nothing
  pending", not "everything succeeded". The failed face sits in `error`. The
  extractor now checks for that explicitly and exits 3 with an explanation,
  rather than emitting numbers that look fine. `--allow-font-errors` opts out for
  iterating on components that use no glyph.

  Also fixed the ordering: fonts are only requested once something needs the
  glyph, so the wait has to come after the story renders, not before.

The committed snapshot is regenerated from --static, which is what CI runs and
the only source that measures correctly. FramedIcon's six variants go 22x28 →
28x28. Re-running against --static now reports "snapshot matches", so the two
environments no longer disagree.
…font-derived widths

Addresses both PR reviews and the second red CI.

Pin actions to SHA (julio-rocketchat)
  checkout, setup-node and upload-artifact were on floating major tags. Pinned to
  the same digests the rest of .github/workflows already uses — checkout v6.0.2,
  setup-node v6.4.0 — plus upload-artifact v4.6.2.

Filesystem race (CodeQL js/file-system-race, alert 110)
  The snapshot comparison did existsSync-then-readFileSync, so the file could
  change between the check and the read. Now it reads first and handles ENOENT.
  Both branches re-verified: matching snapshot, and creating one from absent.

Widths that depend on the host's fonts are no longer snapshotted
  CI failed with exit 2 again, but on different values: Tag came out 34x22 on the
  runner against 32x22 locally, and every text-bearing component was off by ~2px.

  Cause is not the icon font this time. Inter is declared but never actually
  loaded in the Storybook build, so text falls back to a system face, and system
  faces differ between macOS and Linux. That made the snapshot contract
  unsatisfiable: whichever machine generated it, the other one failed.

  The snapshot now records `hug x<height>` instead of an absolute width whenever a
  variant contains text. Nothing is lost — those components are auto-layout HUG in
  Figma, so width is derived from content there too, and padding, height, minWidth
  and the font metrics all stay in the snapshot and are stable across platforms.

  This also retires a claim in the README that was simply false: pinning Chromium
  through the lockfile does not pin the host's fonts, so "measurements reproduce
  across machines" was not true for anything with a glyph in it.

No changeset: @rocket.chat/figma-sync is private, so there is no package to bump.
resize() resets primaryAxisSizingMode and counterAxisSizingMode to FIXED, so
setting them before the resize meant every re-sync read FIXED, wrote AUTO,
resized, and reset again — the apply never reached a fixed point. Moving both
assignments after the resize makes a doubled apply report zero changes.

AUTO also only makes sense with a child to hug. A glyph-only component such as
FramedIcon has no text node, so hugging would collapse it to its padding; its
width comes from the spec instead.

The workflow now comments on the pull request when the extracted spec no longer
matches the committed snapshot, with the commands to re-sync. The snapshot
guards the repository, not the design file, so drift was previously invisible —
which is how the Figma library fell a component behind.
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.

3 participants