feat(figma-sync): generate Figma component library from Storybook - #2140
Draft
ggazzo wants to merge 7 commits into
Draft
feat(figma-sync): generate Figma component library from Storybook#2140ggazzo wants to merge 7 commits into
ggazzo wants to merge 7 commits into
Conversation
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.
|
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
requested changes
Aug 4, 2026
julio-rocketchat
left a comment
Member
There was a problem hiding this comment.
Just a reminder to pin the actions versions as hashes (Layne pointed it out). Thank you 🙏🏻
… 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.
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.
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
getComputedStyleresolvesvar()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: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:
--rcx-color-blue-500does not exist at runtime.warning,secondary-warningandsecondary-successhave nobutton-*custom property, andborder-width/border-radiushave 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
skippedarray incomponents.jsonwith 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
rootSelectoror deciding which args are axes is a reasonable thing to hand to a model; the answer is then frozen intocomponents.jsonand reviewed here, and nothing in the hot path decides anything.yarn workspace @rocket.chat/figma-sync checkplugin/code.jsmatchessrc/apply.jsapply.jskeeps its invariantsvalidateSpecinextract.mjserror-level findingemit-apply.mjs --twiceidempotent: falsefigma-spec.snapshot.jsonis 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/variableswith 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 AppandIcon 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
Skeletonpage in Figma. Its three variants shipped invisible before the wrapper/gradient check existed and the component is now inskipped, 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 avar()chain that reaches a declared custom property. Measuring oneButtonon 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.jsis generated fromsrc/apply.js— don't review it as hand-written, and don't edit it.yarn.lockchanged only to register the new workspace; without ityarn install --immutablefails in CI.figma-spec.jsonis gitignored; it's a build artifact. The committed snapshot is the reviewable digest.