`.
+
+First run needs the browser: `node node_modules/playwright/cli.js install chromium`.
+
+## Adding a component
+
+The measurement is generic; the configuration is not. Every component needs a
+`components.json` entry, so a component added to Fuselage does not appear here on
+its own — `yarn check` fails until it is triaged into one of three buckets:
+
+| Bucket | Meaning |
+| ------------ | -------------------------------------------------------------------- |
+| `components` | shipped, measures correctly |
+| `skipped` | tried, the checks rejected it, reason recorded |
+| `outOfScope` | a composite or layout container we will not attempt, reason recorded |
+
+Start with the scaffold. It derives the mechanical parts — story id, root class
+from the component's `.styles.scss`, and candidate axes from `argTypes`:
+
+```bash
+node src/add-component.mjs Chip --url http://localhost:6006
+node src/add-component.mjs Chip --url http://localhost:6006 --write
+```
+
+What it deliberately leaves to you, because guessing wrong here ships a wrong
+library: which axes are actually _visual_ (Tooltip's `placement` has 11 options
+and changes nothing about the component), which booleans are mutually exclusive
+and need `oneOf`, and what sample `args` make it render meaningfully.
+
+Then measure it and let the checks decide:
+
+```bash
+node src/extract.mjs --url http://localhost:6006 --only Chip
+```
+
+An `error`-level finding means it does not measure — move it to `skipped` with
+the reason rather than shipping it. Three real outcomes from doing exactly this:
+
+- **InputBox** measured cleanly and was kept.
+- **Throbber** — all four variants identical. The dots are children with their own
+ background; the root is a transparent flex wrapper.
+- **Chevron** — 2 distinct renderings out of 5, because `direction` is
+ `transform: rotate()`, which the extractor does not measure.
+
+The entry format:
+
+```json
+{
+ "name": "Tag",
+ "storyId": "data-display-tag--default",
+ "axes": ["variant", "medium", "large"],
+ "args": { "children": "Tag" },
+ "rootSelector": ".rcx-tag"
+}
+```
+
+- `axes` — which args become Figma variant axes. Values come from the story's
+ `argTypes`: a `select` contributes its options, a `boolean` contributes
+ `[false, true]`. Only list args that change appearance (skip `is`, `href`).
+- `rootSelector` — **required.** The story root is a css-in-js `Box` wrapper, not
+ the component; without this you measure a transparent div. Take the root class
+ from the component's `.styles.scss`.
+- `axisValues` — override the values for an axis. `null` means "omit the arg",
+ which is how Fuselage spells an unnamed default (Button at 40px has no size
+ class). Order the values the way the grid should read: Button uses
+ `["small", "medium", null, "large"]` because the unnamed default is 40px, which
+ sits between `medium` (32) and `large` (48). Put `null` first and the variant
+ grid stops being monotonic, which looks exactly like a missing size.
+- `axisLabels` — rename a value in the Figma variant name, e.g. `null` → `default`.
+- `oneOf` — collapses mutually exclusive boolean args into one axis. Fuselage
+ spells some variants and sizes as separate booleans (`Tag` has `medium` and
+ `large`; `FramedIcon` has `info`/`success`/`warning`/`danger`/`neutral`), and
+ crossing those independently generates nonsense combinations like
+ `medium=true, large=true`. `{"size": {"args": ["medium","large"], "noneLabel": "small"}}`
+ produces exactly three variants and sets at most one arg true.
+- `fluidWidth` — opts a component into stretch detection. It is applied **per
+ variant**, not per component: the measured width only counts as fluid when it
+ matches the story canvas. A vertical `Divider` is 1px wide while a horizontal
+ one fills, and a per-component flag would stretch both.
+
+## What makes this deterministic
+
+AI and MCP are fine as transport. No claim about correctness is allowed to depend
+on judgement — each one is a check that fails loudly. The design rule:
+
+> AI decides once, config records it, the pipeline replays it.
+
+Picking a `rootSelector`, deciding which args are axes, writing a `subtitle` — all
+good uses of a model. But the answer is frozen into `components.json`, reviewed in
+a PR, and from then on nothing in the hot path decides anything.
+
+| Guarantee | Enforced by | Fails how |
+| ------------------------------------------ | -------------------------------- | -------------------------------- |
+| Config is well-formed, skips are justified | `yarn check` | non-zero exit |
+| `plugin/code.js` matches `src/apply.js` | `yarn check` | regenerates, then fails |
+| `apply.js` keeps its two invariants | `yarn check` | greps the code, not the comments |
+| Spec shape is 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 | `--twice` scripts | `idempotent: false` |
+| Measurements reproduce across machines | Chromium pinned via the lockfile | — |
+
+**The idempotency contract is the load-bearing one.** `applySpec` reads before it
+writes and counts real changes, so applying the same spec twice must report
+`changes: 0` the second time. Generate the proof scripts with:
+
+```bash
+node src/emit-apply.mjs --twice # one script per component, into .apply/
+```
+
+Run one through `use_figma` and check `idempotent: true`. This is what catches
+code that folds current state into the target — the class of bug where output
+silently depends on history.
+
+Two things the snapshot deliberately does **not** record, both because they are
+rendering outcomes rather than design decisions, and both learned from a red CI:
+
+- **Width of anything containing text.** Inter is declared but never loaded in the
+ Storybook build, so text falls back to a system face — and those differ between
+ macOS and Linux. Tag measured `32x22` locally and `34x22` on the runner, which
+ made the contract unsatisfiable. These components are auto-layout HUG in Figma
+ anyway; padding, height, minWidth and font metrics are what pin the design.
+- **Absolute width where the glyph decides it.** Same rule covers FramedIcon,
+ whose width is set by the icon glyph.
+
+**The snapshot is the second one.** `figma-spec.snapshot.json` is committed. Any
+change to a measured geometry or token binding fails the extractor with a line
+diff, so it lands as a reviewable change in a PR rather than propagating quietly.
+Re-run with `--update-snapshot` and commit when the change is intended.
+
+## Gotchas the hard way
+
+Bugs that produced plausible-looking but wrong output. All fixed; listed because
+each one passed a naive audit, and each now has a check that would catch it.
+
+- **Paints must carry the measured colour AND the alpha.**
+ `setBoundVariableForPaint` does not touch the literal colour, and Figma renders
+ that literal whenever it fails to resolve the alias. Seeding black rendered
+ every Button solid black with a perfectly correct binding in the panel. Dropping
+ the alpha turned `border: 1px solid transparent` into a black outline on every
+ Tag.
+- **Variant property names are capitalised** (`Variant=primary, Size=default`).
+ Emit them lower-case and a re-sync creates a duplicate set instead of updating.
+- **Never fold the current node size into the new size.** `Math.max(target,
+comp.width)` makes a re-sync depend on prior state, so a variant can never
+ shrink back down.
+- **A gradient or image background reads as a transparent `background-color`.**
+ Skeleton's animated shimmer measured as blank and shipped invisible. Only solid
+ paints are emitted, and the extractor now refuses rather than shipping empty.
+
+Keep the matrix under ~30 variants where you can.
+
+**Then read the warnings.** Two of them mean "do not ship this component":
+
+- _ALL n variants measured identically_ — the axes have no effect on
+ `rootSelector`, so the differentiator is in a child, a pseudo-element, or an
+ SVG fill. Root-only measurement cannot see it.
+- _only k distinct renderings across n variants_ — same problem, partially.
+
+`components.json` has a `skipped` array recording every component that failed
+this way and why. Read it before re-adding one.
+
+## The plugin
+
+`plugin/` is a private org plugin: Figma → Plugins → Development → Import from
+manifest, then publish privately.
+
+It updates variant sets **in place**, matching by variant name, so instances in
+other files keep working. Variant names present in Figma but absent from the spec
+are reported as orphaned and left alone — it never deletes.
+
+Token collections must already exist in the target file; the plugin binds to
+them, it does not create them.
+
+## Scope
+
+Phase A ships 11 components / 102 variants: Button, Tag, Badge, Callout, Banner,
+Chip, Label, FramedIcon, Divider, Tooltip, InputBox.
+
+Fourteen more were tried and rejected — see `skipped` in `components.json`. They
+cluster into three fixable gaps, in rough order of value:
+
+1. **Child-node measurement.** ProgressBar's variant colour is on the child bar;
+ CheckBox / RadioButton / ToggleSwitch put their state on a child ``;
+ CodeSnippet's `buttonDisabled` affects a child button. The extractor measures
+ one element, so all of these read as identical variants.
+2. **Shadow extraction.** Tile's `elevation` maps to `box-shadow`, which is not
+ captured — its 5 variants measure as 2. Fuselage's `shadow-elevation-*` tokens
+ are already published in Figma, so this is mostly plumbing.
+3. **SVG / vectors.** StatusBullet is an SVG whose colour is a `path` fill.
+ Icons live here too.
+
+Composites (Table, Modal, Contextualbar, Sidebar) are unlikely to ever be fully
+automatic — author those in Figma by hand.
+
+Also note `Avatar` is skipped for a different reason: its `size` arg is not
+applied by the story at all, so it is a Storybook bug rather than an extractor
+limitation.
diff --git a/tools/figma-sync/components.json b/tools/figma-sync/components.json
new file mode 100644
index 0000000000..c60f6c5d19
--- /dev/null
+++ b/tools/figma-sync/components.json
@@ -0,0 +1,309 @@
+{
+ "$comment": "Phase A: box-shaped atoms. Axis values come from the story's argTypes. rootSelector is REQUIRED — the story root is a css-in-js Box wrapper, not the component; take the class from the component's .styles.scss. axisValues overrides an axis (null = omit the arg, which is how Fuselage spells an unnamed default). oneOf collapses mutually exclusive boolean args into a single axis. fluidWidth marks block-level components whose measured width is only the story canvas.",
+ "components": [
+ {
+ "name": "Button",
+ "storyId": "inputs-button--default",
+ "axes": ["variant", "size"],
+ "axisValues": {
+ "size": ["small", "medium", null, "large"]
+ },
+ "axisLabels": {
+ "size": {
+ "null": "default"
+ }
+ },
+ "args": {
+ "children": "Button"
+ },
+ "rootSelector": ".rcx-button"
+ },
+ {
+ "name": "Tag",
+ "storyId": "data-display-tag--default",
+ "axes": ["variant", "size"],
+ "oneOf": {
+ "size": {
+ "args": ["medium", "large"],
+ "noneLabel": "small"
+ }
+ },
+ "args": {
+ "children": "Tag"
+ },
+ "rootSelector": ".rcx-tag"
+ },
+ {
+ "name": "Badge",
+ "storyId": "data-display-badge--default",
+ "axes": ["variant", "size"],
+ "oneOf": {
+ "size": {
+ "args": ["small"],
+ "noneLabel": "default"
+ }
+ },
+ "args": {
+ "children": "1"
+ },
+ "rootSelector": ".rcx-badge"
+ },
+ {
+ "name": "Callout",
+ "storyId": "feedback-callout--default",
+ "axes": ["type"],
+ "args": {
+ "title": "Callout",
+ "children": "Callout body"
+ },
+ "rootSelector": ".rcx-callout",
+ "fluidWidth": true
+ },
+ {
+ "name": "Banner",
+ "storyId": "feedback-banner--banner",
+ "axes": ["variant", "inline"],
+ "args": {
+ "title": "Banner",
+ "children": "Banner body"
+ },
+ "rootSelector": ".rcx-banner",
+ "fluidWidth": true
+ },
+ {
+ "name": "Chip",
+ "storyId": "data-display-chip--default",
+ "axes": ["disabled"],
+ "args": {
+ "children": "Chip"
+ },
+ "rootSelector": ".rcx-chip"
+ },
+ {
+ "name": "Label",
+ "storyId": "inputs-label--default",
+ "axes": ["required", "disabled"],
+ "args": {
+ "children": "Label"
+ },
+ "rootSelector": ".rcx-label",
+ "fluidWidth": true
+ },
+ {
+ "name": "FramedIcon",
+ "storyId": "data-display-framedicon--default",
+ "axes": ["variant"],
+ "oneOf": {
+ "variant": {
+ "args": ["info", "success", "warning", "danger", "neutral"],
+ "noneLabel": "default"
+ }
+ },
+ "args": {
+ "icon": "info"
+ },
+ "rootSelector": ".rcx-framed-icon"
+ },
+ {
+ "name": "Divider",
+ "storyId": "data-display-divider--default",
+ "axes": ["variation", "vertical"],
+ "axisValues": {
+ "variation": [null, "danger"]
+ },
+ "axisLabels": {
+ "variation": {
+ "null": "default"
+ }
+ },
+ "rootSelector": ".rcx-divider",
+ "fluidWidth": true
+ },
+ {
+ "name": "Tooltip",
+ "storyId": "data-display-tooltip--default",
+ "axes": ["variation"],
+ "args": {
+ "children": "Tooltip"
+ },
+ "rootSelector": ".rcx-tooltip"
+ },
+ {
+ "name": "InputBox",
+ "storyId": "inputs-inputbox--default",
+ "axes": ["small", "disabled"],
+ "args": {
+ "type": "text"
+ },
+ "rootSelector": ".rcx-input-box"
+ }
+ ],
+ "skipped": [
+ {
+ "name": "ProgressBar",
+ "storyId": "data-display-progressbar--default",
+ "rootSelector": ".rcx-progress-bar",
+ "reason": "variant colour is on the child bar, not the root track — needs child-node measurement (Phase B)"
+ },
+ {
+ "name": "CheckBox",
+ "storyId": "inputs-checkbox--default",
+ "rootSelector": ".rcx-check-box",
+ "reason": "checked/indeterminate render on a child ; the root is a transparent 20x20 box"
+ },
+ {
+ "name": "RadioButton",
+ "storyId": "inputs-radiobutton--default",
+ "rootSelector": ".rcx-radio-button",
+ "reason": "same as CheckBox — state lives on a child, root never changes"
+ },
+ {
+ "name": "ToggleSwitch",
+ "storyId": "inputs-toggleswitch--default",
+ "rootSelector": ".rcx-toggle-switch",
+ "reason": "same as CheckBox — state lives on a child, root never changes"
+ },
+ {
+ "name": "StatusBullet",
+ "storyId": "data-display-statusbullet--default",
+ "rootSelector": ".rcx-status-bullet",
+ "reason": "it is an SVG; status colour is the path fill, not a CSS background (Phase B: vectors)"
+ },
+ {
+ "name": "ToastBar",
+ "storyId": "feedback-toastbar--default",
+ "rootSelector": ".rcx-toastbar",
+ "reason": "variant produces no measurable change on the root element"
+ },
+ {
+ "name": "Avatar",
+ "storyId": "data-display-avatar--default",
+ "rootSelector": ".rcx-avatar",
+ "reason": "the size arg is not applied by the story — the class stays rcx-avatar--x16 regardless"
+ },
+ {
+ "name": "Card",
+ "storyId": "containers-card--vertical",
+ "rootSelector": ".rcx-card",
+ "reason": "horizontal/clickable produce no measurable change on the root element"
+ },
+ {
+ "name": "CodeSnippet",
+ "storyId": "data-display-codesnippet--default",
+ "rootSelector": ".rcx-code-snippet",
+ "reason": "buttonDisabled affects a child button, not the root"
+ },
+ {
+ "name": "Tile",
+ "storyId": "containers-tile--default",
+ "rootSelector": ".rcx-tile",
+ "reason": "elevation maps to box-shadow, which the extractor does not capture yet — 5 variants measured as 2. Shadow extraction is the clear next increment: Fuselage already has shadow-elevation-* tokens published in Figma."
+ },
+ {
+ "name": "Bubble",
+ "storyId": "data-display-bubble--icon-and-label",
+ "rootSelector": ".rcx-bubble",
+ "reason": ".rcx-bubble is a layout wrapper: transparent fill, full container width, and unstyled text (measured rgb(0,0,0) — pure black is not in the Fuselage palette). The styled content is in children."
+ },
+ {
+ "name": "Skeleton",
+ "storyId": "layout-skeleton--default",
+ "rootSelector": ".rcx-skeleton",
+ "reason": "the shimmer is an animated linear-gradient background-image, not a background-color, so background-color reads transparent and the variant ships blank. Needs gradient paint support (Figma GRADIENT_LINEAR) plus a decision on how to represent the animation."
+ },
+ {
+ "name": "Throbber",
+ "storyId": "data-display-throbber--default",
+ "rootSelector": ".rcx-throbber",
+ "reason": "the dots are child elements with their own background-color and a bounce animation; .rcx-throbber itself is a transparent flex wrapper, so all variants measure identically. Needs child-node measurement (Phase B)."
+ },
+ {
+ "name": "Chevron",
+ "storyId": "data-display-chevron--default",
+ "rootSelector": ".rcx-chevron",
+ "reason": "direction is expressed as transform: rotate(), which the extractor does not measure — only 2 of 5 variants render distinctly. Figma would need the rotation applied to the node, not a fill change."
+ }
+ ],
+ "outOfScope": [
+ {
+ "name": "Accordion",
+ "reason": "composite — a list of expandable items; each row is its own component"
+ },
+ {
+ "name": "AutoComplete",
+ "reason": "composite — wraps an input plus a floating Options list"
+ },
+ {
+ "name": "ButtonGroup",
+ "reason": "layout container — its appearance is entirely the Buttons inside it"
+ },
+ {
+ "name": "CardGroup",
+ "reason": "layout container — appearance comes from the Cards inside it"
+ },
+ {
+ "name": "Dropdown",
+ "reason": "composite — a floating surface positioned around a trigger"
+ },
+ {
+ "name": "Field",
+ "reason": "composite — label, input, hint and error rows"
+ },
+ {
+ "name": "FieldGroup",
+ "reason": "layout container for Fields"
+ },
+ {
+ "name": "Grid",
+ "reason": "layout primitive — no visual surface of its own"
+ },
+ {
+ "name": "Modal",
+ "reason": "composite — backdrop, surface, header, content and footer"
+ },
+ {
+ "name": "NavBar",
+ "reason": "composite — a bar of other components"
+ },
+ {
+ "name": "Option",
+ "reason": "row inside Options; measured meaningfully only in that context"
+ },
+ {
+ "name": "Options",
+ "reason": "composite — a scrollable list of Option rows"
+ },
+ {
+ "name": "Pagination",
+ "reason": "composite — buttons plus a page-size Select"
+ },
+ {
+ "name": "Select",
+ "reason": "composite — a trigger plus a floating Options list"
+ },
+ {
+ "name": "Sidebar",
+ "reason": "composite — the largest one; rows, headers, search, banners"
+ },
+ {
+ "name": "Sidepanel",
+ "reason": "composite — a panel shell containing other components"
+ },
+ {
+ "name": "States",
+ "reason": "composite — icon, title, subtitle and actions"
+ },
+ {
+ "name": "Table",
+ "reason": "composite — head, body, rows and cells"
+ },
+ {
+ "name": "Tabs",
+ "reason": "composite — a strip of Tab items"
+ },
+ {
+ "name": "Icon",
+ "reason": "a single glyph with no visual axes; belongs to the icon-sprite work (Phase B), and validateSpec requires at least one axis"
+ }
+ ]
+}
diff --git a/tools/figma-sync/figma-spec.snapshot.json b/tools/figma-sync/figma-spec.snapshot.json
new file mode 100644
index 0000000000..351c4310e4
--- /dev/null
+++ b/tools/figma-sync/figma-spec.snapshot.json
@@ -0,0 +1,1948 @@
+{
+ "components": [
+ {
+ "name": "Badge",
+ "storyId": "data-display-badge--default",
+ "axes": {
+ "Variant": ["secondary", "primary", "danger", "warning", "ghost"],
+ "Size": ["default", "small"]
+ },
+ "variants": [
+ {
+ "key": "Variant=danger, Size=default",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 16,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-4",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=danger, Size=small",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 8,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-4",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=ghost, Size=default",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 16,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-stroke-dark",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(108, 115, 122)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=ghost, Size=small",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 8,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-stroke-dark",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(108, 115, 122)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=primary, Size=default",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 16,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-2",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=primary, Size=small",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 8,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-2",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=default",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 16,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-1",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(108, 115, 122)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=small",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 8,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-1",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(108, 115, 122)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=warning, Size=default",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 16,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-3",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(243, 140, 57)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ },
+ {
+ "key": "Variant=warning, Size=small",
+ "size": "hug x16",
+ "padding": "2 4 2 4",
+ "minWidth": 8,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-badge-background-level-3",
+ "textFill": "--rcx-color-font-pure-white"
+ },
+ "values": {
+ "fill": "rgb(243, 140, 57)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 9999
+ }
+ }
+ ]
+ },
+ {
+ "name": "Banner",
+ "storyId": "feedback-banner--banner",
+ "axes": {
+ "Variant": ["neutral", "info", "success", "warning", "danger"],
+ "Inline": ["false", "true"]
+ },
+ "variants": [
+ {
+ "key": "Variant=danger, Inline=false",
+ "size": "hug x73",
+ "padding": "14 16 14 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-danger",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(155, 19, 37)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=danger, Inline=true",
+ "size": "hug x69",
+ "padding": "12 16 12 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-danger",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(155, 19, 37)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=info, Inline=false",
+ "size": "hug x73",
+ "padding": "14 16 14 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-info",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(9, 90, 210)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=info, Inline=true",
+ "size": "hug x69",
+ "padding": "12 16 12 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-info",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(9, 90, 210)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=neutral, Inline=false",
+ "size": "hug x73",
+ "padding": "14 16 14 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=neutral, Inline=true",
+ "size": "hug x69",
+ "padding": "12 16 12 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=success, Inline=false",
+ "size": "hug x73",
+ "padding": "14 16 14 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-success",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=success, Inline=true",
+ "size": "hug x69",
+ "padding": "12 16 12 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-success",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=warning, Inline=false",
+ "size": "hug x73",
+ "padding": "14 16 14 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-warning",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(142, 99, 0)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variant=warning, Inline=true",
+ "size": "hug x69",
+ "padding": "12 16 12 16",
+ "minWidth": 0,
+ "text": "24/24 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "stroke": "--rcx-color-status-font-on-warning",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(142, 99, 0)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 4,
+ "radius": 0
+ }
+ }
+ ]
+ },
+ {
+ "name": "Button",
+ "storyId": "inputs-button--default",
+ "axes": {
+ "Variant": [
+ "primary",
+ "secondary",
+ "danger",
+ "secondary-danger",
+ "warning",
+ "secondary-warning",
+ "success",
+ "secondary-success"
+ ],
+ "Size": ["small", "medium", "default", "large"]
+ },
+ "variants": [
+ {
+ "key": "Variant=danger, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {
+ "fill": "--rcx-color-button-background-danger-default",
+ "stroke": "--rcx-color-button-background-danger-default",
+ "textFill": "--rcx-color-button-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgb(236, 13, 42)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=danger, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {
+ "fill": "--rcx-color-button-background-danger-default",
+ "stroke": "--rcx-color-button-background-danger-default",
+ "textFill": "--rcx-color-button-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgb(236, 13, 42)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=danger, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-danger-default",
+ "stroke": "--rcx-color-button-background-danger-default",
+ "textFill": "--rcx-color-button-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgb(236, 13, 42)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=danger, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-danger-default",
+ "stroke": "--rcx-color-button-background-danger-default",
+ "textFill": "--rcx-color-button-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgb(236, 13, 42)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=primary, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {
+ "fill": "--rcx-color-button-background-primary-default",
+ "stroke": "--rcx-color-button-background-primary-default",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgb(21, 111, 245)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=primary, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {
+ "fill": "--rcx-color-button-background-primary-default",
+ "stroke": "--rcx-color-button-background-primary-default",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgb(21, 111, 245)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=primary, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-primary-default",
+ "stroke": "--rcx-color-button-background-primary-default",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgb(21, 111, 245)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=primary, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-primary-default",
+ "stroke": "--rcx-color-button-background-primary-default",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgb(21, 111, 245)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-danger, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-danger-default",
+ "stroke": "--rcx-color-button-background-secondary-danger-default",
+ "textFill": "--rcx-color-button-font-on-secondary-danger"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(187, 11, 33)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-danger, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-danger-default",
+ "stroke": "--rcx-color-button-background-secondary-danger-default",
+ "textFill": "--rcx-color-button-font-on-secondary-danger"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(187, 11, 33)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-danger, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-danger-default",
+ "stroke": "--rcx-color-button-background-secondary-danger-default",
+ "textFill": "--rcx-color-button-font-on-secondary-danger"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(187, 11, 33)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-danger, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-danger-default",
+ "stroke": "--rcx-color-button-background-secondary-danger-default",
+ "textFill": "--rcx-color-button-font-on-secondary-danger"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(187, 11, 33)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-success, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(20, 134, 96)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-success, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(20, 134, 96)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-success, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(20, 134, 96)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-success, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(20, 134, 96)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-warning, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-warning, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-warning, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary-warning, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "stroke": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-button-font-on-secondary"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "stroke": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-button-font-on-secondary"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "stroke": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-button-font-on-secondary"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "stroke": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-button-font-on-secondary"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=success, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {
+ "fill": "--rcx-color-button-background-success-default",
+ "stroke": "--rcx-color-button-background-success-default",
+ "textFill": "--rcx-color-button-font-on-success"
+ },
+ "values": {
+ "fill": "rgb(20, 134, 96)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=success, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {
+ "fill": "--rcx-color-button-background-success-default",
+ "stroke": "--rcx-color-button-background-success-default",
+ "textFill": "--rcx-color-button-font-on-success"
+ },
+ "values": {
+ "fill": "rgb(20, 134, 96)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=success, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-success-default",
+ "stroke": "--rcx-color-button-background-success-default",
+ "textFill": "--rcx-color-button-font-on-success"
+ },
+ "values": {
+ "fill": "rgb(20, 134, 96)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=success, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-success-default",
+ "stroke": "--rcx-color-button-background-success-default",
+ "textFill": "--rcx-color-button-font-on-success"
+ },
+ "values": {
+ "fill": "rgb(20, 134, 96)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=warning, Size=default",
+ "size": "hug x40",
+ "padding": "8 14 8 14",
+ "minWidth": 80,
+ "text": "14/20 w500",
+ "bind": {},
+ "values": {
+ "fill": "rgb(255, 217, 90)",
+ "stroke": "rgb(255, 217, 90)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=warning, Size=large",
+ "size": "hug x48",
+ "padding": "12 22 12 22",
+ "minWidth": 96,
+ "text": "14/20 w400",
+ "bind": {},
+ "values": {
+ "fill": "rgb(255, 217, 90)",
+ "stroke": "rgb(255, 217, 90)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=warning, Size=medium",
+ "size": "hug x32",
+ "padding": "6 10 6 10",
+ "minWidth": 64,
+ "text": "12/16 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(255, 217, 90)",
+ "stroke": "rgb(255, 217, 90)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=warning, Size=small",
+ "size": "hug x28",
+ "padding": "4 6 4 6",
+ "minWidth": 56,
+ "text": "12/16 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(255, 217, 90)",
+ "stroke": "rgb(255, 217, 90)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ }
+ ]
+ },
+ {
+ "name": "Callout",
+ "storyId": "feedback-callout--default",
+ "axes": {
+ "Type": ["info", "success", "warning", "danger"]
+ },
+ "variants": [
+ {
+ "key": "Type=danger",
+ "size": "hug x70",
+ "padding": "12 12 12 12",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-light",
+ "stroke": "--rcx-color-status-font-on-danger",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(255, 255, 255)",
+ "stroke": "rgb(155, 19, 37)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Type=info",
+ "size": "hug x70",
+ "padding": "12 12 12 12",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-light",
+ "stroke": "--rcx-color-status-font-on-info",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(255, 255, 255)",
+ "stroke": "rgb(9, 90, 210)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Type=success",
+ "size": "hug x70",
+ "padding": "12 12 12 12",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-light",
+ "stroke": "--rcx-color-status-font-on-success",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(255, 255, 255)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Type=warning",
+ "size": "hug x70",
+ "padding": "12 12 12 12",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-light",
+ "stroke": "--rcx-color-status-font-on-warning",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(255, 255, 255)",
+ "stroke": "rgb(142, 99, 0)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ }
+ ]
+ },
+ {
+ "name": "Chip",
+ "storyId": "data-display-chip--default",
+ "axes": {
+ "Disabled": ["false", "true"]
+ },
+ "variants": [
+ {
+ "key": "Disabled=false",
+ "size": "hug x28",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": "14/20 w400",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "stroke": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-font-secondary-info"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(108, 115, 122)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Disabled=true",
+ "size": "hug x28",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": "14/20 w400",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "stroke": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-font-secondary-info"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(228, 231, 234)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ }
+ ]
+ },
+ {
+ "name": "Divider",
+ "storyId": "data-display-divider--default",
+ "axes": {
+ "Variation": ["default", "danger"],
+ "Vertical": ["false", "true"]
+ },
+ "variants": [
+ {
+ "key": "Variation=danger, Vertical=false",
+ "size": "fluid x1",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": null,
+ "bind": {
+ "stroke": "--rcx-color-stroke-error"
+ },
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(236, 13, 42)",
+ "textFill": "rgb(128, 128, 128)",
+ "strokeWeight": 1,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variation=danger, Vertical=true",
+ "size": "1x20",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": null,
+ "bind": {
+ "stroke": "--rcx-color-stroke-error"
+ },
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(236, 13, 42)",
+ "textFill": "rgb(128, 128, 128)",
+ "strokeWeight": 1,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variation=default, Vertical=false",
+ "size": "fluid x1",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": null,
+ "bind": {},
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(235, 236, 239)",
+ "textFill": "rgb(128, 128, 128)",
+ "strokeWeight": 1,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Variation=default, Vertical=true",
+ "size": "1x20",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": null,
+ "bind": {},
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(235, 236, 239)",
+ "textFill": "rgb(128, 128, 128)",
+ "strokeWeight": 1,
+ "radius": 0
+ }
+ }
+ ]
+ },
+ {
+ "name": "FramedIcon",
+ "storyId": "data-display-framedicon--default",
+ "axes": {
+ "Variant": [
+ "default",
+ "info",
+ "success",
+ "warning",
+ "danger",
+ "neutral"
+ ]
+ },
+ "variants": [
+ {
+ "key": "Variant=danger",
+ "size": "hug x28",
+ "padding": "4 4 4 4",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-status-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(155, 19, 37)",
+ "textFill": "rgb(155, 19, 37)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=default",
+ "size": "hug x28",
+ "padding": "4 4 4 4",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-font-secondary-info"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(108, 115, 122)",
+ "textFill": "rgb(108, 115, 122)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=info",
+ "size": "hug x28",
+ "padding": "4 4 4 4",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-status-font-on-info"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(9, 90, 210)",
+ "textFill": "rgb(9, 90, 210)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=neutral",
+ "size": "hug x28",
+ "padding": "4 4 4 4",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-font-secondary-info"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(108, 115, 122)",
+ "textFill": "rgb(108, 115, 122)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=success",
+ "size": "hug x28",
+ "padding": "4 4 4 4",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-status-font-on-success"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(20, 134, 96)",
+ "textFill": "rgb(20, 134, 96)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variant=warning",
+ "size": "hug x28",
+ "padding": "4 4 4 4",
+ "minWidth": 0,
+ "text": "20/20 w400",
+ "bind": {
+ "fill": "--rcx-color-surface-tint",
+ "textFill": "--rcx-color-status-font-on-warning"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(142, 99, 0)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ }
+ ]
+ },
+ {
+ "name": "InputBox",
+ "storyId": "inputs-inputbox--default",
+ "axes": {
+ "Small": ["false", "true"],
+ "Disabled": ["false", "true"]
+ },
+ "variants": [
+ {
+ "key": "Small=false, Disabled=false",
+ "size": "128x40",
+ "padding": "8 15 8 15",
+ "minWidth": 128,
+ "text": null,
+ "bind": {
+ "stroke": "--rcx-color-stroke-light",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(255, 255, 255)",
+ "stroke": "rgb(203, 206, 209)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Small=false, Disabled=true",
+ "size": "128x40",
+ "padding": "8 15 8 15",
+ "minWidth": 128,
+ "text": null,
+ "bind": {
+ "stroke": "--rcx-color-stroke-light",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(203, 206, 209)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Small=true, Disabled=false",
+ "size": "112x28",
+ "padding": "4 8 4 8",
+ "minWidth": 112,
+ "text": null,
+ "bind": {
+ "stroke": "--rcx-color-stroke-light",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(255, 255, 255)",
+ "stroke": "rgb(203, 206, 209)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Small=true, Disabled=true",
+ "size": "112x28",
+ "padding": "4 8 4 8",
+ "minWidth": 112,
+ "text": null,
+ "bind": {
+ "stroke": "--rcx-color-stroke-light",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(247, 248, 250)",
+ "stroke": "rgb(203, 206, 209)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 1,
+ "radius": 4
+ }
+ }
+ ]
+ },
+ {
+ "name": "Label",
+ "storyId": "inputs-label--default",
+ "axes": {
+ "Required": ["false", "true"],
+ "Disabled": ["false", "true"]
+ },
+ "variants": [
+ {
+ "key": "Required=false, Disabled=false",
+ "size": "hug x20",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": "14/20 w500",
+ "bind": {
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(47, 52, 61)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 0,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Required=false, Disabled=true",
+ "size": "hug x20",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": "14/20 w500",
+ "bind": {
+ "textFill": "--rcx-color-font-secondary-info"
+ },
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(108, 115, 122)",
+ "textFill": "rgb(108, 115, 122)",
+ "strokeWeight": 0,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Required=true, Disabled=false",
+ "size": "hug x20",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": "14/20 w500",
+ "bind": {
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(47, 52, 61)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 0,
+ "radius": 0
+ }
+ },
+ {
+ "key": "Required=true, Disabled=true",
+ "size": "hug x20",
+ "padding": "0 0 0 0",
+ "minWidth": 0,
+ "text": "14/20 w500",
+ "bind": {
+ "textFill": "--rcx-color-font-secondary-info"
+ },
+ "values": {
+ "fill": "rgba(0, 0, 0, 0)",
+ "stroke": "rgb(108, 115, 122)",
+ "textFill": "rgb(108, 115, 122)",
+ "strokeWeight": 0,
+ "radius": 0
+ }
+ }
+ ]
+ },
+ {
+ "name": "Tag",
+ "storyId": "data-display-tag--default",
+ "axes": {
+ "Variant": [
+ "primary",
+ "secondary",
+ "danger",
+ "warning",
+ "secondary-danger",
+ "secondary-warning",
+ "secondary-info",
+ "featured"
+ ],
+ "Size": ["small", "medium", "large"]
+ },
+ "variants": [
+ {
+ "key": "Variant=danger, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-danger-default",
+ "textFill": "--rcx-color-button-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=danger, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-danger-default",
+ "textFill": "--rcx-color-button-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=danger, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-danger-default",
+ "textFill": "--rcx-color-button-font-on-danger"
+ },
+ "values": {
+ "fill": "rgb(236, 13, 42)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=featured, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {
+ "fill": "--rcx-color-surface-featured",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(95, 20, 119)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=featured, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-surface-featured",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(95, 20, 119)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=featured, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-surface-featured",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(95, 20, 119)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=primary, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-primary-default",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=primary, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-primary-default",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=primary, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-primary-default",
+ "textFill": "--rcx-color-button-font-on-primary"
+ },
+ "values": {
+ "fill": "rgb(21, 111, 245)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-danger, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-danger-default",
+ "textFill": "--rcx-color-button-font-on-secondary-danger"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(187, 11, 33)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-danger, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-danger-default",
+ "textFill": "--rcx-color-button-font-on-secondary-danger"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(187, 11, 33)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-danger, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-danger-default",
+ "textFill": "--rcx-color-button-font-on-secondary-danger"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(187, 11, 33)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-info, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-status-font-on-info"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(9, 90, 210)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-info, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-status-font-on-info"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(9, 90, 210)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-info, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-status-font-on-info"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(9, 90, 210)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-warning, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-status-font-on-warning"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-warning, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-status-font-on-warning"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary-warning, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-status-font-on-warning"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(142, 99, 0)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-button-font-on-secondary"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-button-font-on-secondary"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=secondary, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {
+ "fill": "--rcx-color-button-background-secondary-default",
+ "textFill": "--rcx-color-button-font-on-secondary"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=warning, Size=large",
+ "size": "hug x26",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "14/20 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(255, 217, 90)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=warning, Size=medium",
+ "size": "hug x22",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "12/16 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(255, 217, 90)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ },
+ {
+ "key": "Variant=warning, Size=small",
+ "size": "hug x18",
+ "padding": "2 4 2 4",
+ "minWidth": 0,
+ "text": "10/12 w700",
+ "bind": {},
+ "values": {
+ "fill": "rgb(255, 217, 90)",
+ "stroke": "rgba(0, 0, 0, 0)",
+ "textFill": "rgb(31, 35, 41)",
+ "strokeWeight": 1,
+ "radius": 2
+ }
+ }
+ ]
+ },
+ {
+ "name": "Tooltip",
+ "storyId": "data-display-tooltip--default",
+ "axes": {
+ "Variation": ["dark", "light"]
+ },
+ "variants": [
+ {
+ "key": "Variation=dark",
+ "size": "hug x36",
+ "padding": "8 12 8 12",
+ "minWidth": 0,
+ "text": "14/20 w500",
+ "bind": {
+ "fill": "--rcx-color-surface-dark",
+ "textFill": "--rcx-color-font-white"
+ },
+ "values": {
+ "fill": "rgb(31, 35, 41)",
+ "stroke": "rgb(255, 255, 255)",
+ "textFill": "rgb(255, 255, 255)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ },
+ {
+ "key": "Variation=light",
+ "size": "hug x36",
+ "padding": "8 12 8 12",
+ "minWidth": 0,
+ "text": "14/20 w500",
+ "bind": {
+ "fill": "--rcx-color-surface-neutral",
+ "textFill": "--rcx-color-font-default"
+ },
+ "values": {
+ "fill": "rgb(228, 231, 234)",
+ "stroke": "rgb(47, 52, 61)",
+ "textFill": "rgb(47, 52, 61)",
+ "strokeWeight": 0,
+ "radius": 4
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/tools/figma-sync/package.json b/tools/figma-sync/package.json
new file mode 100644
index 0000000000..559ea6af39
--- /dev/null
+++ b/tools/figma-sync/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@rocket.chat/figma-sync",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "bin": "./src/extract.mjs",
+ "scripts": {
+ "extract": "node ./src/extract.mjs",
+ "emit-apply": "node ./src/emit-apply.mjs",
+ "build-plugin": "node ./src/emit-apply.mjs --plugin",
+ "check": "node ./src/selfcheck.mjs",
+ "add-component": "node ./src/add-component.mjs"
+ },
+ "dependencies": {
+ "playwright": "~1.62.0"
+ }
+}
diff --git a/tools/figma-sync/plugin/code.js b/tools/figma-sync/plugin/code.js
new file mode 100644
index 0000000000..7d344bca4c
--- /dev/null
+++ b/tools/figma-sync/plugin/code.js
@@ -0,0 +1,510 @@
+/* eslint-disable no-undef */
+/**
+ * GENERATED FILE — do not edit.
+ * Run `node src/emit-apply.mjs --plugin` to regenerate.
+ *
+ * The apply logic below is inlined verbatim from src/apply.js so the plugin and
+ * the MCP path share one implementation.
+ */
+
+/**
+ * Applies a figma-spec.json to a Figma file. Single source of truth for the
+ * apply step — both the plugin (`plugin/code.js`, generated) and the MCP path
+ * (`src/emit-apply.mjs`) run this exact code.
+ *
+ * Two rules make this deterministic, and both exist because breaking them
+ * produced real bugs:
+ *
+ * 1. Every write is preceded by a read-and-compare, and only happens when the
+ * value actually differs. `applySpec` therefore reports a `changes` count,
+ * and running it twice must report 0 the second time. Without this, code that
+ * folds current state into the target (`Math.max(target, node.width)`) looks
+ * fine and silently makes the result depend on history.
+ * 2. Nothing here decides anything. Every value comes from the spec. If the
+ * output is wrong, the extractor is wrong.
+ *
+ * Runs inside the Figma plugin sandbox: plain JS, no imports, no Node globals.
+ */
+
+function applySpec(figma, spec) {
+ const q = (x) => Math.round(x * 255);
+
+ const parseColor = (str) => {
+ if (!str) return null;
+ const m = String(str).match(/rgba?\(([^)]+)\)/);
+ if (m) {
+ const p = m[1].split(',').map((x) => parseFloat(x.trim()));
+ return {
+ r: p[0] / 255,
+ g: p[1] / 255,
+ b: p[2] / 255,
+ a: p.length > 3 ? p[3] : 1,
+ };
+ }
+ const h = String(str).replace('#', '');
+ if (!/^[0-9a-fA-F]{6,8}$/.test(h)) return null;
+ const n = parseInt(h.slice(0, 6), 16);
+ return {
+ r: ((n >> 16) & 255) / 255,
+ g: ((n >> 8) & 255) / 255,
+ b: (n & 255) / 255,
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
+ };
+ };
+
+ const sameColor = (a, b) =>
+ !!a &&
+ !!b &&
+ q(a.r) === q(b.r) &&
+ q(a.g) === q(b.g) &&
+ q(a.b) === q(b.b) &&
+ Math.abs((a.a === undefined ? 1 : a.a) - (b.a === undefined ? 1 : b.a)) <
+ 0.01;
+
+ const weightToStyle = (w) =>
+ w >= 800
+ ? 'Extra Bold'
+ : w >= 700
+ ? 'Bold'
+ : w >= 600
+ ? 'Semi Bold'
+ : w >= 500
+ ? 'Medium'
+ : 'Regular';
+
+ /** `--rcx-color-button-background-primary-default` -> `button-background-primary-default` */
+ const cssVarToFigmaName = (cssVar) => {
+ for (const p of ['--rcx-color-', '--rcx-']) {
+ if (cssVar.startsWith(p)) return cssVar.slice(p.length);
+ }
+ return cssVar.replace(/^--/, '');
+ };
+
+ const report = {
+ changes: 0,
+ changed: [],
+ components: [],
+ resolution: { byName: 0, byValue: 0, unresolved: [] },
+ };
+
+ const note = (what) => {
+ report.changes += 1;
+ if (report.changed.length < 40) report.changed.push(what);
+ };
+
+ /** The read-compare-write primitive. Returns true when it wrote. */
+ const set = (node, key, value, where) => {
+ if (node[key] === value) return false;
+ node[key] = value;
+ note(`${where}.${key}`);
+ return true;
+ };
+
+ return (async () => {
+ const collections =
+ await figma.variables.getLocalVariableCollectionsAsync();
+ const allVars = await figma.variables.getLocalVariablesAsync();
+ const byName = new Map(allVars.map((v) => [v.name, v]));
+
+ const colorByValue = [];
+ const floatByValue = [];
+ for (const v of allVars) {
+ const col = collections.find((c) => c.id === v.variableCollectionId);
+ if (!col) continue;
+ const raw = v.valuesByMode[col.modes[0].modeId];
+ if (!raw || raw.type === 'VARIABLE_ALIAS') continue;
+ if (v.resolvedType === 'COLOR') {
+ colorByValue.push({ v, color: raw, col: col.name });
+ }
+ if (v.resolvedType === 'FLOAT') {
+ floatByValue.push({ v, value: raw, col: col.name });
+ }
+ }
+ // Primitives win value-matching ties: a component token that happens to
+ // share a value is a coincidence, a primitive with that value is the source.
+ const orderedColors = colorByValue
+ .slice()
+ .sort(
+ (a, b) =>
+ (a.col === 'Primitives' ? -1 : 0) - (b.col === 'Primitives' ? -1 : 0),
+ );
+
+ const resolveColor = (cssVar, measured, where) => {
+ if (cssVar) {
+ const hit = byName.get(cssVarToFigmaName(cssVar));
+ if (hit) {
+ report.resolution.byName += 1;
+ return hit;
+ }
+ }
+ const target = parseColor(measured);
+ const hit =
+ target && orderedColors.find((c) => sameColor(c.color, target));
+ if (hit) {
+ report.resolution.byValue += 1;
+ return hit.v;
+ }
+ report.resolution.unresolved.push(`${where}: ${measured}`);
+ return null;
+ };
+
+ const resolveFloat = (cssVar, measured, where) => {
+ if (cssVar) {
+ const hit = byName.get(cssVarToFigmaName(cssVar));
+ if (hit) {
+ report.resolution.byName += 1;
+ return hit;
+ }
+ }
+ const c = floatByValue.filter((f) => f.value === measured);
+ const hit = c.find((x) => x.col === 'Layout') || c[0];
+ if (hit) {
+ report.resolution.byValue += 1;
+ return hit.v;
+ }
+ report.resolution.unresolved.push(`${where}: ${measured}`);
+ return null;
+ };
+
+ /**
+ * Paints carry BOTH the measured colour and its alpha, then the variable on
+ * top. `setBoundVariableForPaint` leaves the literal alone and Figma renders
+ * that literal whenever it fails to resolve the alias, so seeding black
+ * renders solid black with a correct-looking binding; dropping the alpha
+ * turns `border: 1px solid transparent` into a black outline.
+ */
+ const desiredPaint = (variable, measured) => {
+ const c = measured || { r: 0, g: 0, b: 0, a: 1 };
+ const base = {
+ type: 'SOLID',
+ color: { r: c.r, g: c.g, b: c.b },
+ opacity: c.a === undefined ? 1 : c.a,
+ };
+ return variable
+ ? figma.variables.setBoundVariableForPaint(base, 'color', variable)
+ : base;
+ };
+
+ const samePaintList = (current, desired) => {
+ if (!current || current.length !== desired.length) return false;
+ for (let i = 0; i < desired.length; i++) {
+ const a = current[i];
+ const b = desired[i];
+ if (!a || a.type !== b.type) return false;
+ if (!sameColor({ ...a.color, a: 1 }, { ...b.color, a: 1 }))
+ return false;
+ const ao = a.opacity === undefined ? 1 : a.opacity;
+ const bo = b.opacity === undefined ? 1 : b.opacity;
+ if (Math.abs(ao - bo) > 0.01) return false;
+ const av = a.boundVariables?.color?.id || null;
+ const bv = b.boundVariables?.color?.id || null;
+ if (av !== bv) return false;
+ }
+ return true;
+ };
+
+ const setPaints = (node, key, desired, where) => {
+ if (samePaintList(node[key], desired)) return;
+ node[key] = desired;
+ note(`${where}.${key}`);
+ };
+
+ const setBound = (node, field, variable, where) => {
+ const current = node.boundVariables?.[field]?.id || null;
+ const target = variable ? variable.id : null;
+ if (current === target) return;
+ if (variable) node.setBoundVariable(field, variable);
+ note(`${where}.${field}`);
+ };
+
+ async function applyVariant(comp, v, where) {
+ const L = v.layout;
+ set(comp, 'name', v.key, where);
+ set(
+ comp,
+ 'layoutMode',
+ L.direction === 'VERTICAL' ? 'VERTICAL' : 'HORIZONTAL',
+ where,
+ );
+ set(comp, 'primaryAxisAlignItems', 'CENTER', where);
+ set(comp, 'counterAxisAlignItems', 'CENTER', where);
+ set(comp, 'paddingTop', L.paddingTop, where);
+ set(comp, 'paddingRight', L.paddingRight, where);
+ set(comp, 'paddingBottom', L.paddingBottom, where);
+ set(comp, 'paddingLeft', L.paddingLeft, where);
+ if (L.gap) set(comp, 'itemSpacing', L.gap, where);
+ set(comp, 'minWidth', L.minWidth || null, where);
+
+ let label = comp.findOne((n) => n.type === 'TEXT');
+ if (v.text && v.text.content) {
+ const style = weightToStyle(v.text.fontWeight);
+ await figma.loadFontAsync({ family: 'Inter', style });
+ if (!label) {
+ label = figma.createText();
+ label.name = spec.textChild || 'label';
+ comp.appendChild(label);
+ note(`${where}.label created`);
+ } else if (label.fontName && label.fontName.family) {
+ await figma.loadFontAsync(label.fontName);
+ }
+ if (
+ label.fontName.family !== 'Inter' ||
+ label.fontName.style !== style
+ ) {
+ label.fontName = { family: 'Inter', style };
+ note(`${where}.label.fontName`);
+ }
+ set(label, 'fontSize', v.text.fontSize, `${where}.label`);
+ const lh = label.lineHeight;
+ if (!lh || lh.unit !== 'PIXELS' || lh.value !== v.text.lineHeight) {
+ label.lineHeight = { unit: 'PIXELS', value: v.text.lineHeight };
+ note(`${where}.label.lineHeight`);
+ }
+ set(label, 'characters', v.text.content, `${where}.label`);
+ set(label, 'layoutSizingHorizontal', 'HUG', `${where}.label`);
+ set(label, 'layoutSizingVertical', 'HUG', `${where}.label`);
+ } else if (label) {
+ label.remove();
+ label = null;
+ note(`${where}.label removed`);
+ }
+
+ // Never fold comp.width in: that makes the result depend on prior state
+ // and a variant can never shrink back down on a re-sync.
+ const targetW = L.fluidWidth
+ ? L.minWidth || 320
+ : L.width || L.minWidth || 1;
+ const h = L.height || 0;
+ if (h > 0) {
+ const w = Math.max(targetW, 1);
+ if (
+ Math.round(comp.width) !== Math.round(w) ||
+ Math.round(comp.height) !== Math.round(h)
+ ) {
+ comp.resize(w, h);
+ note(`${where}.size`);
+ }
+ }
+
+ // Sizing modes go AFTER resize, never before: resize() resets them to
+ // FIXED, so setting them first means the next sync reads FIXED, writes
+ // AUTO, resizes, and resets again — the apply never converges. The
+ // idempotency contract is what surfaced this.
+ //
+ // AUTO only makes sense when there is a child to hug. A glyph-only
+ // component like FramedIcon has no text node, so hugging would collapse it
+ // to its padding; its width comes from the spec instead.
+ set(comp, 'primaryAxisSizingMode', label ? 'AUTO' : 'FIXED', where);
+ set(comp, 'counterAxisSizingMode', 'FIXED', where);
+
+ const cFill = parseColor(v.values.fill);
+ const fv = resolveColor(v.bind.fill, v.values.fill, `${where} fill`);
+ setPaints(comp, 'fills', [desiredPaint(fv, cFill)], where);
+
+ if (v.values.strokeWeight > 0) {
+ const cStroke = parseColor(v.values.stroke);
+ const sv = resolveColor(
+ v.bind.stroke,
+ v.values.stroke,
+ `${where} stroke`,
+ );
+ setPaints(comp, 'strokes', [desiredPaint(sv, cStroke)], where);
+ set(comp, 'strokeAlign', 'INSIDE', where);
+ set(comp, 'strokeWeight', v.values.strokeWeight, where);
+ const swv = resolveFloat(
+ v.bind.strokeWeight,
+ v.values.strokeWeight,
+ `${where} strokeWeight`,
+ );
+ for (const f of [
+ 'strokeTopWeight',
+ 'strokeBottomWeight',
+ 'strokeLeftWeight',
+ 'strokeRightWeight',
+ ]) {
+ setBound(comp, f, swv, where);
+ }
+ } else if (comp.strokes && comp.strokes.length) {
+ comp.strokes = [];
+ note(`${where}.strokes cleared`);
+ }
+
+ if (label) {
+ const cText = parseColor(v.values.textFill);
+ const tv = resolveColor(
+ v.bind.textFill,
+ v.values.textFill,
+ `${where} textFill`,
+ );
+ setPaints(label, 'fills', [desiredPaint(tv, cText)], `${where}.label`);
+ }
+
+ set(comp, 'cornerRadius', v.values.radius || 0, where);
+ if (v.values.radius > 0) {
+ const rv = resolveFloat(
+ v.bind.radius,
+ v.values.radius,
+ `${where} radius`,
+ );
+ for (const f of [
+ 'topLeftRadius',
+ 'topRightRadius',
+ 'bottomLeftRadius',
+ 'bottomRightRadius',
+ ]) {
+ setBound(comp, f, rv, where);
+ }
+ }
+ }
+
+ for (const component of spec.components) {
+ let page = figma.root.children.find((p) => p.name === component.name);
+ if (!page) {
+ page = figma.createPage();
+ page.name = component.name;
+ note(`page ${component.name} created`);
+ }
+ await figma.setCurrentPageAsync(page);
+
+ let set_ = page.findOne(
+ (n) => n.type === 'COMPONENT_SET' && n.name === component.name,
+ );
+ const existing = new Map();
+ if (set_) for (const c of set_.children) existing.set(c.name, c);
+
+ let created = 0;
+ let updated = 0;
+ const comps = [];
+ for (const v of component.variants) {
+ let comp = existing.get(v.key);
+ if (comp) {
+ updated += 1;
+ existing.delete(v.key);
+ } else {
+ comp = figma.createComponent();
+ created += 1;
+ note(`${component.name}/${v.key} created`);
+ if (set_) set_.appendChild(comp);
+ else page.appendChild(comp);
+ }
+ await applyVariant(comp, v, `${component.name}/${v.key}`);
+ comps.push(comp);
+ }
+ const orphaned = [...existing.keys()];
+
+ if (!set_) {
+ set_ = figma.combineAsVariants(comps, page);
+ set_.name = component.name;
+ note(`${component.name} set created`);
+ }
+ const description =
+ 'Generated by @rocket.chat/figma-sync from Fuselage Storybook (' +
+ component.storyId +
+ ').\nGeometry and colours are measured from the rendered component, not ' +
+ 'hand-authored. Re-running the sync overwrites them.';
+ set(set_, 'description', description, component.name);
+
+ // Variants stack at 0,0 after combineAsVariants, so the grid is explicit.
+ const axisNames = Object.keys(component.axes);
+ const cols = component.axes[axisNames[axisNames.length - 1]] || [''];
+ const colW =
+ Math.max.apply(
+ null,
+ component.variants.map(
+ (v) => v.layout.width || v.layout.minWidth || 320,
+ ),
+ ) + 48;
+ const rowH =
+ Math.max.apply(
+ null,
+ component.variants.map((v) => v.layout.height || 40),
+ ) + 40;
+ set(set_, 'layoutMode', 'NONE', component.name);
+ const rows = [];
+ for (const child of set_.children) {
+ const parts = child.name.split(', ');
+ const colLabel = parts[parts.length - 1].split('=')[1];
+ const rowLabel = parts.slice(0, -1).join(', ') || 'row';
+ if (!rows.includes(rowLabel)) rows.push(rowLabel);
+ const x = 40 + Math.max(0, cols.indexOf(colLabel)) * colW;
+ const y = 40 + rows.indexOf(rowLabel) * rowH;
+ set(child, 'x', x, `${component.name}/${child.name}`);
+ set(child, 'y', y, `${component.name}/${child.name}`);
+ }
+ const setW = 80 + cols.length * colW;
+ const setH = 80 + rows.length * rowH;
+ if (
+ Math.round(set_.width) !== Math.round(setW) ||
+ Math.round(set_.height) !== Math.round(setH)
+ ) {
+ set_.resize(setW, setH);
+ note(`${component.name} set size`);
+ }
+ set(set_, 'x', 0, component.name);
+ set(set_, 'y', 0, component.name);
+
+ report.components.push({
+ name: component.name,
+ created,
+ updated,
+ orphaned,
+ variants: set_.children.length,
+ });
+ }
+
+ return report;
+ })();
+}
+
+
+figma.showUI(__html__, { width: 420, height: 520 });
+
+figma.ui.onmessage = async (msg) => {
+ if (msg.type !== 'sync') return;
+ try {
+ let spec;
+ if (msg.json) {
+ spec = JSON.parse(msg.json);
+ } else if (msg.url) {
+ const res = await fetch(msg.url);
+ if (!res.ok) throw new Error('fetch ' + msg.url + ' -> HTTP ' + res.status);
+ spec = await res.json();
+ } else {
+ throw new Error('provide a spec URL or paste the JSON');
+ }
+
+ const vars = await figma.variables.getLocalVariablesAsync();
+ if (vars.length === 0) {
+ throw new Error(
+ 'this file has no variables — publish the token collections before syncing components',
+ );
+ }
+
+ const report = await applySpec(figma, spec);
+ const results = report.components.map(
+ (c) =>
+ c.name +
+ ': ' +
+ c.created +
+ ' created, ' +
+ c.updated +
+ ' updated, ' +
+ c.variants +
+ ' variants' +
+ (c.orphaned.length ? ', ' + c.orphaned.length + ' orphaned (left alone)' : ''),
+ );
+ const log = [
+ report.changes + ' property write(s)',
+ 'bindings: ' +
+ report.resolution.byName +
+ ' by name, ' +
+ report.resolution.byValue +
+ ' by value, ' +
+ report.resolution.unresolved.length +
+ ' unresolved',
+ ];
+ figma.ui.postMessage({ type: 'done', results, log });
+ } catch (e) {
+ figma.ui.postMessage({ type: 'error', message: e.message, log: [] });
+ }
+};
diff --git a/tools/figma-sync/plugin/manifest.json b/tools/figma-sync/plugin/manifest.json
new file mode 100644
index 0000000000..3600b83355
--- /dev/null
+++ b/tools/figma-sync/plugin/manifest.json
@@ -0,0 +1,16 @@
+{
+ "name": "Fuselage Sync",
+ "id": "fuselage-figma-sync",
+ "api": "1.0.0",
+ "main": "code.js",
+ "ui": "ui.html",
+ "editorType": ["figma"],
+ "documentAccess": "dynamic-page",
+ "networkAccess": {
+ "allowedDomains": [
+ "https://rocketchat.github.io",
+ "https://raw.githubusercontent.com"
+ ],
+ "reasoning": "Fetches figma-spec.json, the component spec generated by CI from Fuselage's Storybook."
+ }
+}
diff --git a/tools/figma-sync/plugin/ui.html b/tools/figma-sync/plugin/ui.html
new file mode 100644
index 0000000000..7ab89595c8
--- /dev/null
+++ b/tools/figma-sync/plugin/ui.html
@@ -0,0 +1,52 @@
+
+
+Fuselage Sync
+Applies the component spec generated by CI from Storybook.
+
+
+
+
+
+
+
+
+Token collections must already exist in this file.
+
+
diff --git a/tools/figma-sync/src/add-component.mjs b/tools/figma-sync/src/add-component.mjs
new file mode 100644
index 0000000000..84067c7a0e
--- /dev/null
+++ b/tools/figma-sync/src/add-component.mjs
@@ -0,0 +1,205 @@
+#!/usr/bin/env node
+/**
+ * Proposes a components.json entry for a component, doing the mechanical parts
+ * so a human only has to review a judgement call.
+ *
+ * node src/add-component.mjs Chip --url http://localhost:6006
+ * node src/add-component.mjs Chip --url http://localhost:6006 --write
+ *
+ * Derived automatically (no judgement needed):
+ * storyId from the Storybook index
+ * rootSelector first `.rcx-*` class in the component's .styles.scss
+ * axes select and boolean argTypes, minus DENY below
+ *
+ * Left to a human, because getting these wrong is how you ship a wrong library:
+ * which axes actually matter visually — `placement` has 11 options and changes
+ * nothing about the component itself, `oneOf` grouping for mutually exclusive
+ * booleans, sample `args`, and whether the component belongs in scope at all.
+ *
+ * Nothing is written unless you pass --write, and even then the diff is the
+ * review surface. Run `extract --only ` afterwards: the error-level checks
+ * are what tell you whether the proposal actually measures.
+ */
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+import { chromium } from 'playwright';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.join(__dirname, '..');
+const REPO = path.join(ROOT, '..', '..');
+
+const arg = (name, fallback) => {
+ const i = process.argv.indexOf(`--${name}`);
+ return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
+ ? process.argv[i + 1]
+ : fallback;
+};
+const flag = (name) => process.argv.includes(`--${name}`);
+
+// Args that exist for polymorphism, wiring or content rather than appearance.
+// Anything here still shows up in the report so you can override the guess.
+const DENY = new Set([
+ 'is',
+ 'href',
+ 'external',
+ 'onClick',
+ 'onClose',
+ 'onChange',
+ 'children',
+ 'className',
+ 'placement',
+ 'linkTarget',
+ 'objectFit',
+ 'type',
+ 'icon',
+ 'title',
+ 'percentage',
+]);
+
+const name = process.argv[2];
+if (!name || name.startsWith('--')) {
+ console.error(
+ 'usage: node src/add-component.mjs [--url ] [--write]',
+ );
+ process.exit(1);
+}
+
+/** The root class the component's own stylesheet declares. */
+function rootSelectorFor(componentName) {
+ const scss = path.join(
+ REPO,
+ 'packages/fuselage/src/components',
+ componentName,
+ `${componentName}.styles.scss`,
+ );
+ if (!fs.existsSync(scss)) return null;
+ const m = fs.readFileSync(scss, 'utf-8').match(/^\.(rcx-[a-z0-9-]+)/m);
+ return m ? `.${m[1]}` : null;
+}
+
+const baseUrl = arg('url', 'http://localhost:6006');
+
+const browser = await chromium.launch();
+const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
+
+const index = await (await fetch(`${baseUrl}/index.json`)).json();
+const stories = Object.values(index.entries).filter((e) => e.type === 'story');
+const mine = stories.filter((e) => e.title.split('/').pop() === name);
+if (!mine.length) {
+ console.error(`no stories found whose title ends in "${name}"`);
+ console.error(
+ 'available: ' +
+ [...new Set(stories.map((e) => e.title.split('/').pop()))]
+ .sort()
+ .join(', '),
+ );
+ await browser.close();
+ process.exit(1);
+}
+const story =
+ mine.find((e) => /^default$/i.test(e.name)) ||
+ mine.find((e) => /default/i.test(e.name)) ||
+ mine[0];
+
+await page.goto(`${baseUrl}/iframe.html?id=${story.id}&viewMode=story`, {
+ waitUntil: 'domcontentloaded',
+});
+// The store throws "index is not ready" until a story has actually rendered.
+await page.waitForFunction(() => !!window.__STORYBOOK_STORY_STORE__, {
+ timeout: 60_000,
+});
+await page.waitForSelector('#storybook-root > *', { timeout: 60_000 });
+
+const argTypes = await page.evaluate(async (storyId) => {
+ const st = await window.__STORYBOOK_STORY_STORE__.loadStory({ storyId });
+ const out = {};
+ for (const [k, v] of Object.entries(st.argTypes || {})) {
+ const t = v?.control?.type ?? v?.control;
+ if (t === 'select' || t === 'radio' || t === 'inline-radio') {
+ out[k] = { kind: 'select', options: v.options || [] };
+ } else if (t === 'boolean') {
+ out[k] = { kind: 'boolean' };
+ }
+ }
+ return out;
+}, story.id);
+
+await browser.close();
+
+const rootSelector = rootSelectorFor(name);
+const proposedAxes = Object.entries(argTypes)
+ .filter(([k]) => !DENY.has(k))
+ .map(([k, v]) => k);
+
+const entry = {
+ name,
+ storyId: story.id,
+ axes: proposedAxes,
+ rootSelector:
+ rootSelector || 'TODO — no .styles.scss found, find the root class by hand',
+};
+
+const matrixSize = proposedAxes.reduce((n, k) => {
+ const a = argTypes[k];
+ return n * (a.kind === 'boolean' ? 2 : a.options.length || 1);
+}, 1);
+
+console.log(`\n${name} (${mine.length} stories, using ${story.id})\n`);
+console.log('argTypes found:');
+for (const [k, v] of Object.entries(argTypes)) {
+ const shape =
+ v.kind === 'boolean' ? 'boolean' : `select[${v.options.length}]`;
+ const verdict = DENY.has(k)
+ ? 'skipped (not a visual axis)'
+ : 'proposed as axis';
+ console.log(` ${k.padEnd(18)} ${shape.padEnd(12)} ${verdict}`);
+}
+
+console.log(`\nrootSelector: ${rootSelector || 'NOT FOUND'}`);
+console.log(`proposed matrix: ${matrixSize} variants`);
+if (matrixSize > 30) {
+ console.log(
+ ' ^ over ~30. Drop an axis, or group mutually exclusive booleans with oneOf.',
+ );
+}
+if (!rootSelector) {
+ console.log(
+ ' ^ without a rootSelector you measure the css-in-js Box wrapper, not the component.',
+ );
+}
+
+console.log('\nproposed entry:\n');
+console.log(JSON.stringify(entry, null, 2));
+
+console.log(
+ [
+ '',
+ 'Review before trusting it:',
+ ' - are all those axes actually visual? drop the ones that are not',
+ ' - are any of them mutually exclusive booleans? use oneOf',
+ ' - does it need sample args (children, title) to render meaningfully?',
+ '',
+ `Then measure it: node src/extract.mjs --url ${baseUrl} --only ${name}`,
+ 'An error-level finding means it does not measure — move it to skipped with',
+ 'the reason rather than shipping it.',
+ ].join('\n'),
+);
+
+if (flag('write')) {
+ const cfgPath = path.join(ROOT, 'components.json');
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
+ const already = [
+ ...cfg.components,
+ ...cfg.skipped,
+ ...(cfg.outOfScope || []),
+ ].find((c) => c.name === name);
+ if (already) {
+ console.error(`\n${name} is already in components.json — edit it by hand`);
+ process.exit(1);
+ }
+ cfg.components.push(entry);
+ fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
+ console.log(`\nappended to ${cfgPath} — review the diff, then run extract`);
+}
diff --git a/tools/figma-sync/src/apply.js b/tools/figma-sync/src/apply.js
new file mode 100644
index 0000000000..7ce71400a9
--- /dev/null
+++ b/tools/figma-sync/src/apply.js
@@ -0,0 +1,448 @@
+/**
+ * Applies a figma-spec.json to a Figma file. Single source of truth for the
+ * apply step — both the plugin (`plugin/code.js`, generated) and the MCP path
+ * (`src/emit-apply.mjs`) run this exact code.
+ *
+ * Two rules make this deterministic, and both exist because breaking them
+ * produced real bugs:
+ *
+ * 1. Every write is preceded by a read-and-compare, and only happens when the
+ * value actually differs. `applySpec` therefore reports a `changes` count,
+ * and running it twice must report 0 the second time. Without this, code that
+ * folds current state into the target (`Math.max(target, node.width)`) looks
+ * fine and silently makes the result depend on history.
+ * 2. Nothing here decides anything. Every value comes from the spec. If the
+ * output is wrong, the extractor is wrong.
+ *
+ * Runs inside the Figma plugin sandbox: plain JS, no imports, no Node globals.
+ */
+
+export function applySpec(figma, spec) {
+ const q = (x) => Math.round(x * 255);
+
+ const parseColor = (str) => {
+ if (!str) return null;
+ const m = String(str).match(/rgba?\(([^)]+)\)/);
+ if (m) {
+ const p = m[1].split(',').map((x) => parseFloat(x.trim()));
+ return {
+ r: p[0] / 255,
+ g: p[1] / 255,
+ b: p[2] / 255,
+ a: p.length > 3 ? p[3] : 1,
+ };
+ }
+ const h = String(str).replace('#', '');
+ if (!/^[0-9a-fA-F]{6,8}$/.test(h)) return null;
+ const n = parseInt(h.slice(0, 6), 16);
+ return {
+ r: ((n >> 16) & 255) / 255,
+ g: ((n >> 8) & 255) / 255,
+ b: (n & 255) / 255,
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
+ };
+ };
+
+ const sameColor = (a, b) =>
+ !!a &&
+ !!b &&
+ q(a.r) === q(b.r) &&
+ q(a.g) === q(b.g) &&
+ q(a.b) === q(b.b) &&
+ Math.abs((a.a === undefined ? 1 : a.a) - (b.a === undefined ? 1 : b.a)) <
+ 0.01;
+
+ const weightToStyle = (w) =>
+ w >= 800
+ ? 'Extra Bold'
+ : w >= 700
+ ? 'Bold'
+ : w >= 600
+ ? 'Semi Bold'
+ : w >= 500
+ ? 'Medium'
+ : 'Regular';
+
+ /** `--rcx-color-button-background-primary-default` -> `button-background-primary-default` */
+ const cssVarToFigmaName = (cssVar) => {
+ for (const p of ['--rcx-color-', '--rcx-']) {
+ if (cssVar.startsWith(p)) return cssVar.slice(p.length);
+ }
+ return cssVar.replace(/^--/, '');
+ };
+
+ const report = {
+ changes: 0,
+ changed: [],
+ components: [],
+ resolution: { byName: 0, byValue: 0, unresolved: [] },
+ };
+
+ const note = (what) => {
+ report.changes += 1;
+ if (report.changed.length < 40) report.changed.push(what);
+ };
+
+ /** The read-compare-write primitive. Returns true when it wrote. */
+ const set = (node, key, value, where) => {
+ if (node[key] === value) return false;
+ node[key] = value;
+ note(`${where}.${key}`);
+ return true;
+ };
+
+ return (async () => {
+ const collections =
+ await figma.variables.getLocalVariableCollectionsAsync();
+ const allVars = await figma.variables.getLocalVariablesAsync();
+ const byName = new Map(allVars.map((v) => [v.name, v]));
+
+ const colorByValue = [];
+ const floatByValue = [];
+ for (const v of allVars) {
+ const col = collections.find((c) => c.id === v.variableCollectionId);
+ if (!col) continue;
+ const raw = v.valuesByMode[col.modes[0].modeId];
+ if (!raw || raw.type === 'VARIABLE_ALIAS') continue;
+ if (v.resolvedType === 'COLOR') {
+ colorByValue.push({ v, color: raw, col: col.name });
+ }
+ if (v.resolvedType === 'FLOAT') {
+ floatByValue.push({ v, value: raw, col: col.name });
+ }
+ }
+ // Primitives win value-matching ties: a component token that happens to
+ // share a value is a coincidence, a primitive with that value is the source.
+ const orderedColors = colorByValue
+ .slice()
+ .sort(
+ (a, b) =>
+ (a.col === 'Primitives' ? -1 : 0) - (b.col === 'Primitives' ? -1 : 0),
+ );
+
+ const resolveColor = (cssVar, measured, where) => {
+ if (cssVar) {
+ const hit = byName.get(cssVarToFigmaName(cssVar));
+ if (hit) {
+ report.resolution.byName += 1;
+ return hit;
+ }
+ }
+ const target = parseColor(measured);
+ const hit =
+ target && orderedColors.find((c) => sameColor(c.color, target));
+ if (hit) {
+ report.resolution.byValue += 1;
+ return hit.v;
+ }
+ report.resolution.unresolved.push(`${where}: ${measured}`);
+ return null;
+ };
+
+ const resolveFloat = (cssVar, measured, where) => {
+ if (cssVar) {
+ const hit = byName.get(cssVarToFigmaName(cssVar));
+ if (hit) {
+ report.resolution.byName += 1;
+ return hit;
+ }
+ }
+ const c = floatByValue.filter((f) => f.value === measured);
+ const hit = c.find((x) => x.col === 'Layout') || c[0];
+ if (hit) {
+ report.resolution.byValue += 1;
+ return hit.v;
+ }
+ report.resolution.unresolved.push(`${where}: ${measured}`);
+ return null;
+ };
+
+ /**
+ * Paints carry BOTH the measured colour and its alpha, then the variable on
+ * top. `setBoundVariableForPaint` leaves the literal alone and Figma renders
+ * that literal whenever it fails to resolve the alias, so seeding black
+ * renders solid black with a correct-looking binding; dropping the alpha
+ * turns `border: 1px solid transparent` into a black outline.
+ */
+ const desiredPaint = (variable, measured) => {
+ const c = measured || { r: 0, g: 0, b: 0, a: 1 };
+ const base = {
+ type: 'SOLID',
+ color: { r: c.r, g: c.g, b: c.b },
+ opacity: c.a === undefined ? 1 : c.a,
+ };
+ return variable
+ ? figma.variables.setBoundVariableForPaint(base, 'color', variable)
+ : base;
+ };
+
+ const samePaintList = (current, desired) => {
+ if (!current || current.length !== desired.length) return false;
+ for (let i = 0; i < desired.length; i++) {
+ const a = current[i];
+ const b = desired[i];
+ if (!a || a.type !== b.type) return false;
+ if (!sameColor({ ...a.color, a: 1 }, { ...b.color, a: 1 }))
+ return false;
+ const ao = a.opacity === undefined ? 1 : a.opacity;
+ const bo = b.opacity === undefined ? 1 : b.opacity;
+ if (Math.abs(ao - bo) > 0.01) return false;
+ const av = a.boundVariables?.color?.id || null;
+ const bv = b.boundVariables?.color?.id || null;
+ if (av !== bv) return false;
+ }
+ return true;
+ };
+
+ const setPaints = (node, key, desired, where) => {
+ if (samePaintList(node[key], desired)) return;
+ node[key] = desired;
+ note(`${where}.${key}`);
+ };
+
+ const setBound = (node, field, variable, where) => {
+ const current = node.boundVariables?.[field]?.id || null;
+ const target = variable ? variable.id : null;
+ if (current === target) return;
+ if (variable) node.setBoundVariable(field, variable);
+ note(`${where}.${field}`);
+ };
+
+ async function applyVariant(comp, v, where) {
+ const L = v.layout;
+ set(comp, 'name', v.key, where);
+ set(
+ comp,
+ 'layoutMode',
+ L.direction === 'VERTICAL' ? 'VERTICAL' : 'HORIZONTAL',
+ where,
+ );
+ set(comp, 'primaryAxisAlignItems', 'CENTER', where);
+ set(comp, 'counterAxisAlignItems', 'CENTER', where);
+ set(comp, 'paddingTop', L.paddingTop, where);
+ set(comp, 'paddingRight', L.paddingRight, where);
+ set(comp, 'paddingBottom', L.paddingBottom, where);
+ set(comp, 'paddingLeft', L.paddingLeft, where);
+ if (L.gap) set(comp, 'itemSpacing', L.gap, where);
+ set(comp, 'minWidth', L.minWidth || null, where);
+
+ let label = comp.findOne((n) => n.type === 'TEXT');
+ if (v.text && v.text.content) {
+ const style = weightToStyle(v.text.fontWeight);
+ await figma.loadFontAsync({ family: 'Inter', style });
+ if (!label) {
+ label = figma.createText();
+ label.name = spec.textChild || 'label';
+ comp.appendChild(label);
+ note(`${where}.label created`);
+ } else if (label.fontName && label.fontName.family) {
+ await figma.loadFontAsync(label.fontName);
+ }
+ if (
+ label.fontName.family !== 'Inter' ||
+ label.fontName.style !== style
+ ) {
+ label.fontName = { family: 'Inter', style };
+ note(`${where}.label.fontName`);
+ }
+ set(label, 'fontSize', v.text.fontSize, `${where}.label`);
+ const lh = label.lineHeight;
+ if (!lh || lh.unit !== 'PIXELS' || lh.value !== v.text.lineHeight) {
+ label.lineHeight = { unit: 'PIXELS', value: v.text.lineHeight };
+ note(`${where}.label.lineHeight`);
+ }
+ set(label, 'characters', v.text.content, `${where}.label`);
+ set(label, 'layoutSizingHorizontal', 'HUG', `${where}.label`);
+ set(label, 'layoutSizingVertical', 'HUG', `${where}.label`);
+ } else if (label) {
+ label.remove();
+ label = null;
+ note(`${where}.label removed`);
+ }
+
+ // Never fold comp.width in: that makes the result depend on prior state
+ // and a variant can never shrink back down on a re-sync.
+ const targetW = L.fluidWidth
+ ? L.minWidth || 320
+ : L.width || L.minWidth || 1;
+ const h = L.height || 0;
+ if (h > 0) {
+ const w = Math.max(targetW, 1);
+ if (
+ Math.round(comp.width) !== Math.round(w) ||
+ Math.round(comp.height) !== Math.round(h)
+ ) {
+ comp.resize(w, h);
+ note(`${where}.size`);
+ }
+ }
+
+ // Sizing modes go AFTER resize, never before: resize() resets them to
+ // FIXED, so setting them first means the next sync reads FIXED, writes
+ // AUTO, resizes, and resets again — the apply never converges. The
+ // idempotency contract is what surfaced this.
+ //
+ // AUTO only makes sense when there is a child to hug. A glyph-only
+ // component like FramedIcon has no text node, so hugging would collapse it
+ // to its padding; its width comes from the spec instead.
+ set(comp, 'primaryAxisSizingMode', label ? 'AUTO' : 'FIXED', where);
+ set(comp, 'counterAxisSizingMode', 'FIXED', where);
+
+ const cFill = parseColor(v.values.fill);
+ const fv = resolveColor(v.bind.fill, v.values.fill, `${where} fill`);
+ setPaints(comp, 'fills', [desiredPaint(fv, cFill)], where);
+
+ if (v.values.strokeWeight > 0) {
+ const cStroke = parseColor(v.values.stroke);
+ const sv = resolveColor(
+ v.bind.stroke,
+ v.values.stroke,
+ `${where} stroke`,
+ );
+ setPaints(comp, 'strokes', [desiredPaint(sv, cStroke)], where);
+ set(comp, 'strokeAlign', 'INSIDE', where);
+ set(comp, 'strokeWeight', v.values.strokeWeight, where);
+ const swv = resolveFloat(
+ v.bind.strokeWeight,
+ v.values.strokeWeight,
+ `${where} strokeWeight`,
+ );
+ for (const f of [
+ 'strokeTopWeight',
+ 'strokeBottomWeight',
+ 'strokeLeftWeight',
+ 'strokeRightWeight',
+ ]) {
+ setBound(comp, f, swv, where);
+ }
+ } else if (comp.strokes && comp.strokes.length) {
+ comp.strokes = [];
+ note(`${where}.strokes cleared`);
+ }
+
+ if (label) {
+ const cText = parseColor(v.values.textFill);
+ const tv = resolveColor(
+ v.bind.textFill,
+ v.values.textFill,
+ `${where} textFill`,
+ );
+ setPaints(label, 'fills', [desiredPaint(tv, cText)], `${where}.label`);
+ }
+
+ set(comp, 'cornerRadius', v.values.radius || 0, where);
+ if (v.values.radius > 0) {
+ const rv = resolveFloat(
+ v.bind.radius,
+ v.values.radius,
+ `${where} radius`,
+ );
+ for (const f of [
+ 'topLeftRadius',
+ 'topRightRadius',
+ 'bottomLeftRadius',
+ 'bottomRightRadius',
+ ]) {
+ setBound(comp, f, rv, where);
+ }
+ }
+ }
+
+ for (const component of spec.components) {
+ let page = figma.root.children.find((p) => p.name === component.name);
+ if (!page) {
+ page = figma.createPage();
+ page.name = component.name;
+ note(`page ${component.name} created`);
+ }
+ await figma.setCurrentPageAsync(page);
+
+ let set_ = page.findOne(
+ (n) => n.type === 'COMPONENT_SET' && n.name === component.name,
+ );
+ const existing = new Map();
+ if (set_) for (const c of set_.children) existing.set(c.name, c);
+
+ let created = 0;
+ let updated = 0;
+ const comps = [];
+ for (const v of component.variants) {
+ let comp = existing.get(v.key);
+ if (comp) {
+ updated += 1;
+ existing.delete(v.key);
+ } else {
+ comp = figma.createComponent();
+ created += 1;
+ note(`${component.name}/${v.key} created`);
+ if (set_) set_.appendChild(comp);
+ else page.appendChild(comp);
+ }
+ await applyVariant(comp, v, `${component.name}/${v.key}`);
+ comps.push(comp);
+ }
+ const orphaned = [...existing.keys()];
+
+ if (!set_) {
+ set_ = figma.combineAsVariants(comps, page);
+ set_.name = component.name;
+ note(`${component.name} set created`);
+ }
+ const description =
+ 'Generated by @rocket.chat/figma-sync from Fuselage Storybook (' +
+ component.storyId +
+ ').\nGeometry and colours are measured from the rendered component, not ' +
+ 'hand-authored. Re-running the sync overwrites them.';
+ set(set_, 'description', description, component.name);
+
+ // Variants stack at 0,0 after combineAsVariants, so the grid is explicit.
+ const axisNames = Object.keys(component.axes);
+ const cols = component.axes[axisNames[axisNames.length - 1]] || [''];
+ const colW =
+ Math.max.apply(
+ null,
+ component.variants.map(
+ (v) => v.layout.width || v.layout.minWidth || 320,
+ ),
+ ) + 48;
+ const rowH =
+ Math.max.apply(
+ null,
+ component.variants.map((v) => v.layout.height || 40),
+ ) + 40;
+ set(set_, 'layoutMode', 'NONE', component.name);
+ const rows = [];
+ for (const child of set_.children) {
+ const parts = child.name.split(', ');
+ const colLabel = parts[parts.length - 1].split('=')[1];
+ const rowLabel = parts.slice(0, -1).join(', ') || 'row';
+ if (!rows.includes(rowLabel)) rows.push(rowLabel);
+ const x = 40 + Math.max(0, cols.indexOf(colLabel)) * colW;
+ const y = 40 + rows.indexOf(rowLabel) * rowH;
+ set(child, 'x', x, `${component.name}/${child.name}`);
+ set(child, 'y', y, `${component.name}/${child.name}`);
+ }
+ const setW = 80 + cols.length * colW;
+ const setH = 80 + rows.length * rowH;
+ if (
+ Math.round(set_.width) !== Math.round(setW) ||
+ Math.round(set_.height) !== Math.round(setH)
+ ) {
+ set_.resize(setW, setH);
+ note(`${component.name} set size`);
+ }
+ set(set_, 'x', 0, component.name);
+ set(set_, 'y', 0, component.name);
+
+ report.components.push({
+ name: component.name,
+ created,
+ updated,
+ orphaned,
+ variants: set_.children.length,
+ });
+ }
+
+ return report;
+ })();
+}
diff --git a/tools/figma-sync/src/emit-apply.mjs b/tools/figma-sync/src/emit-apply.mjs
new file mode 100644
index 0000000000..f738a92031
--- /dev/null
+++ b/tools/figma-sync/src/emit-apply.mjs
@@ -0,0 +1,205 @@
+#!/usr/bin/env node
+/**
+ * Generates the two apply entry points from the single implementation in
+ * `apply.js`, so the plugin and the MCP path can never drift apart.
+ *
+ * node src/emit-apply.mjs # one script per component, into .apply/
+ * node src/emit-apply.mjs --twice # each script applies twice and asserts idempotency
+ * node src/emit-apply.mjs --only Button
+ * node src/emit-apply.mjs --plugin # regenerate plugin/code.js
+ *
+ * The per-component split exists because `use_figma` caps its `code` payload at
+ * 50k characters and the full spec plus apply.js is larger than that.
+ */
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.join(__dirname, '..');
+
+const arg = (name, fallback) => {
+ const i = process.argv.indexOf(`--${name}`);
+ return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
+ ? process.argv[i + 1]
+ : fallback;
+};
+const flag = (name) => process.argv.includes(`--${name}`);
+
+const MAX_CODE = 50_000;
+
+/** apply.js with its ESM export removed, so it can be inlined verbatim. */
+const applySource = () =>
+ fs
+ .readFileSync(path.join(__dirname, 'apply.js'), 'utf-8')
+ .replace(/^export function applySpec/m, 'function applySpec');
+
+/** Only the fields apply.js reads — keeps each emitted script under the cap. */
+const slimComponent = (c) => ({
+ name: c.name,
+ storyId: c.storyId,
+ textChild: c.textChild,
+ axes: c.axes,
+ variants: c.variants.map((v) => ({
+ key: v.key,
+ layout: v.layout,
+ text: v.text && v.text.content ? v.text : null,
+ bind: v.bind,
+ values: v.values,
+ })),
+});
+
+const summarise = `
+const summarise = (r) => ({
+ changes: r.changes,
+ changedSample: r.changed.slice(0, 12),
+ components: r.components,
+ resolution: {
+ byName: r.resolution.byName,
+ byValue: r.resolution.byValue,
+ unresolved: r.resolution.unresolved.length,
+ unresolvedSample: r.resolution.unresolved.slice(0, 5),
+ },
+});`;
+
+function emitOnce(component) {
+ return `${applySource()}
+const SPEC = ${JSON.stringify({ components: [slimComponent(component)] })};
+${summarise}
+return summarise(await applySpec(figma, SPEC));
+`;
+}
+
+function emitTwice(component) {
+ return `${applySource()}
+const SPEC = ${JSON.stringify({ components: [slimComponent(component)] })};
+${summarise}
+// Idempotency contract: applying the same spec twice must be a no-op the second
+// time. A non-zero second pass means some write depends on prior node state.
+const first = await applySpec(figma, SPEC);
+const second = await applySpec(figma, SPEC);
+return {
+ component: ${JSON.stringify(component.name)},
+ idempotent: second.changes === 0,
+ first: summarise(first),
+ second: summarise(second),
+};
+`;
+}
+
+function emitPlugin() {
+ return `/* eslint-disable no-undef */
+/**
+ * GENERATED FILE — do not edit.
+ * Run \`node src/emit-apply.mjs --plugin\` to regenerate.
+ *
+ * The apply logic below is inlined verbatim from src/apply.js so the plugin and
+ * the MCP path share one implementation.
+ */
+
+${applySource()}
+
+figma.showUI(__html__, { width: 420, height: 520 });
+
+figma.ui.onmessage = async (msg) => {
+ if (msg.type !== 'sync') return;
+ try {
+ let spec;
+ if (msg.json) {
+ spec = JSON.parse(msg.json);
+ } else if (msg.url) {
+ const res = await fetch(msg.url);
+ if (!res.ok) throw new Error('fetch ' + msg.url + ' -> HTTP ' + res.status);
+ spec = await res.json();
+ } else {
+ throw new Error('provide a spec URL or paste the JSON');
+ }
+
+ const vars = await figma.variables.getLocalVariablesAsync();
+ if (vars.length === 0) {
+ throw new Error(
+ 'this file has no variables — publish the token collections before syncing components',
+ );
+ }
+
+ const report = await applySpec(figma, spec);
+ const results = report.components.map(
+ (c) =>
+ c.name +
+ ': ' +
+ c.created +
+ ' created, ' +
+ c.updated +
+ ' updated, ' +
+ c.variants +
+ ' variants' +
+ (c.orphaned.length ? ', ' + c.orphaned.length + ' orphaned (left alone)' : ''),
+ );
+ const log = [
+ report.changes + ' property write(s)',
+ 'bindings: ' +
+ report.resolution.byName +
+ ' by name, ' +
+ report.resolution.byValue +
+ ' by value, ' +
+ report.resolution.unresolved.length +
+ ' unresolved',
+ ];
+ figma.ui.postMessage({ type: 'done', results, log });
+ } catch (e) {
+ figma.ui.postMessage({ type: 'error', message: e.message, log: [] });
+ }
+};
+`;
+}
+
+const specPath = path.resolve(arg('spec', path.join(ROOT, 'figma-spec.json')));
+
+if (flag('plugin')) {
+ const out = path.join(ROOT, 'plugin', 'code.js');
+ fs.writeFileSync(out, emitPlugin());
+ console.log(`wrote ${out} (generated from src/apply.js)`);
+ process.exit(0);
+}
+
+if (!fs.existsSync(specPath)) {
+ console.error(`spec not found: ${specPath}\nrun src/extract.mjs first`);
+ process.exit(1);
+}
+const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
+const only = arg('only', null);
+const components = spec.components.filter((c) => !only || c.name === only);
+if (!components.length) {
+ console.error(`no components matched${only ? ` --only ${only}` : ''}`);
+ process.exit(1);
+}
+
+const outDir = path.resolve(arg('out-dir', path.join(ROOT, '.apply')));
+fs.mkdirSync(outDir, { recursive: true });
+
+const twice = flag('twice');
+let oversize = 0;
+components.forEach((c, i) => {
+ const code = twice ? emitTwice(c) : emitOnce(c);
+ const file = path.join(
+ outDir,
+ `${String(i + 1).padStart(2, '0')}-${c.name}.js`,
+ );
+ fs.writeFileSync(file, code);
+ const kb = (code.length / 1024).toFixed(1);
+ const over = code.length > MAX_CODE;
+ if (over) oversize += 1;
+ console.log(
+ `${c.name.padEnd(12)} ${String(c.variants.length).padStart(3)} variants ${kb.padStart(6)} kB${over ? ' <<< OVER 50k use_figma LIMIT' : ''}`,
+ );
+});
+
+console.log(
+ `\n${components.length} script(s) -> ${outDir}${twice ? ' (each applies twice and asserts idempotency)' : ''}`,
+);
+if (oversize) {
+ console.error(
+ `\n${oversize} script(s) exceed the 50k use_figma limit — split the component's axes.`,
+ );
+ process.exit(1);
+}
diff --git a/tools/figma-sync/src/extract.mjs b/tools/figma-sync/src/extract.mjs
new file mode 100755
index 0000000000..54e2d16ba3
--- /dev/null
+++ b/tools/figma-sync/src/extract.mjs
@@ -0,0 +1,622 @@
+#!/usr/bin/env node
+/**
+ * Reads a running (or statically served) Storybook and emits figma-spec.json:
+ * a declarative description of every component variant, with each visual
+ * property bound to the CSS custom property the code actually references.
+ *
+ * The Figma plugin consumes this file and does no thinking of its own.
+ *
+ * node src/extract.mjs --url http://localhost:6006 --out figma-spec.json
+ * node src/extract.mjs --static ../../packages/fuselage/storybook-static
+ */
+import fs from 'fs';
+import path from 'path';
+import http from 'http';
+import { fileURLToPath } from 'url';
+
+import { chromium } from 'playwright';
+
+import { measureElement } from './measure.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.join(__dirname, '..');
+
+const arg = (name, fallback) => {
+ const i = process.argv.indexOf(`--${name}`);
+ return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
+};
+
+const flag = (name) => process.argv.includes(`--${name}`);
+
+const OUT = path.resolve(
+ arg('out', path.join(__dirname, '..', 'figma-spec.json')),
+);
+const CONFIG = JSON.parse(
+ fs.readFileSync(
+ path.resolve(arg('config', path.join(__dirname, '..', 'components.json'))),
+ 'utf-8',
+ ),
+);
+const ONLY = arg('only', null);
+
+/** Serve storybook-static so --static behaves like --url. */
+const serveStatic = (dir) =>
+ new Promise((resolve) => {
+ const types = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.map': 'application/json',
+ '.svg': 'image/svg+xml',
+ '.woff2': 'font/woff2',
+ '.woff': 'font/woff',
+ '.ttf': 'font/ttf',
+ '.png': 'image/png',
+ };
+ const server = http.createServer((req, res) => {
+ const rel =
+ decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '') ||
+ 'index.html';
+ const file = path.join(dir, rel);
+ if (!file.startsWith(path.resolve(dir))) {
+ res.writeHead(403).end();
+ return;
+ }
+ fs.readFile(file, (err, buf) => {
+ if (err) {
+ res.writeHead(404).end();
+ return;
+ }
+ res.writeHead(200, {
+ 'content-type':
+ types[path.extname(file)] || 'application/octet-stream',
+ });
+ res.end(buf);
+ });
+ });
+ server.listen(0, '127.0.0.1', () =>
+ resolve({ server, url: `http://127.0.0.1:${server.address().port}` }),
+ );
+ });
+
+/**
+ * Cross product of axis values, in config order.
+ *
+ * A `oneOf` axis collapses several mutually exclusive boolean args into one
+ * axis. Fuselage spells sizes and variants as separate booleans (Tag has
+ * `medium` and `large`; FramedIcon has `info`/`success`/`warning`/`danger`), so
+ * crossing them independently generates nonsense like `medium=true, large=true`.
+ */
+const matrix = (axes) => {
+ let rows = [{ __args: {} }];
+ for (const axis of axes) {
+ const next = [];
+ for (const row of rows) {
+ for (const v of axis.values) {
+ const args = { ...row.__args };
+ if (axis.oneOf) {
+ for (const a of axis.oneOf.args) args[a] = v === a;
+ } else {
+ args[axis.name] = v;
+ }
+ next.push({ ...row, [axis.name]: v, __args: args });
+ }
+ }
+ rows = next;
+ }
+ return rows;
+};
+
+/** Storybook's ?args= encoding: key:value pairs joined by ';'. Booleans as !true/!false. */
+const encodeArgs = (args) =>
+ Object.entries(args)
+ .filter(([, v]) => v !== null && v !== undefined)
+ .map(([k, v]) => {
+ if (typeof v === 'boolean') return `${k}:!${v}`;
+ if (typeof v === 'number') return `${k}:${v}`;
+ return `${k}:${String(v).replace(/[;:&]/g, '')}`;
+ })
+ .join(';');
+
+// Figma variant properties are conventionally capitalised, and an existing set
+// built as "Variant=primary, Size=default" must be matched exactly or the sync
+// creates a duplicate instead of updating in place.
+const propName = (argName) =>
+ argName.charAt(0).toUpperCase() + argName.slice(1);
+
+const labelFor = (component, axis, value) => {
+ const override = component.axisLabels?.[axis]?.[String(value)];
+ if (override) return override;
+ if (value === null || value === undefined) return 'default';
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
+ return String(value);
+};
+
+/**
+ * Structural contract for the emitted spec, as executable assertions rather
+ * than a .schema.json file. One source of truth beats a schema that can drift
+ * from the code that produces it, and a failed assertion names the exact path.
+ */
+function validateSpec(spec) {
+ const errs = [];
+ const req = (cond, msg) => {
+ if (!cond) errs.push(msg);
+ };
+ req(Array.isArray(spec.components), 'components must be an array');
+ for (const c of spec.components || []) {
+ const at = `components[${c.name}]`;
+ req(typeof c.name === 'string' && c.name.length > 0, `${at}: name`);
+ req(
+ typeof c.storyId === 'string' && c.storyId.includes('--'),
+ `${at}: storyId`,
+ );
+ req(
+ c.axes && Object.keys(c.axes).length > 0,
+ `${at}: axes must be non-empty`,
+ );
+ req(Array.isArray(c.variants) && c.variants.length > 0, `${at}: variants`);
+
+ const expected = Object.values(c.axes || {}).reduce(
+ (n, v) => n * v.length,
+ 1,
+ );
+ req(
+ c.variants.length === expected,
+ `${at}: ${c.variants.length} variants but axes imply ${expected}`,
+ );
+
+ const keys = new Set();
+ for (const v of c.variants || []) {
+ const vat = `${at}.variants[${v.key}]`;
+ req(typeof v.key === 'string' && v.key.includes('='), `${vat}: key`);
+ req(!keys.has(v.key), `${vat}: duplicate key`);
+ keys.add(v.key);
+ // Figma variant properties are capitalised; lower-case keys create a
+ // duplicate component set on the next sync instead of updating.
+ for (const part of String(v.key).split(', ')) {
+ const prop = part.split('=')[0];
+ req(
+ prop && prop[0] === prop[0].toUpperCase(),
+ `${vat}: property "${prop}" must be capitalised`,
+ );
+ }
+ req(
+ v.layout && typeof v.layout.height === 'number',
+ `${vat}: layout.height`,
+ );
+ req(
+ v.layout.fluidWidth === true || typeof v.layout.width === 'number',
+ `${vat}: needs layout.width unless fluidWidth`,
+ );
+ req(v.values && typeof v.values.fill === 'string', `${vat}: values.fill`);
+ req(typeof v.values.radius === 'number', `${vat}: values.radius`);
+ req(
+ typeof v.values.strokeWeight === 'number',
+ `${vat}: values.strokeWeight`,
+ );
+ req(v.bind && typeof v.bind === 'object', `${vat}: bind`);
+ for (const [k, cssVar] of Object.entries(v.bind || {})) {
+ req(
+ typeof cssVar === 'string' && cssVar.startsWith('--'),
+ `${vat}: bind.${k} must be a CSS custom property name`,
+ );
+ }
+ req(
+ 'containerWidth' in v.layout === false,
+ `${vat}: containerWidth must be stripped`,
+ );
+ }
+ }
+ return errs;
+}
+
+/**
+ * A normalised, order-stable digest of the spec, committed to the repo so a
+ * token or geometry change shows up as a reviewable diff in a PR instead of
+ * propagating silently. This is what catches "surface-overlay quietly lost its
+ * alpha three releases ago".
+ */
+function snapshotOf(spec) {
+ return {
+ components: spec.components
+ .map((c) => ({
+ name: c.name,
+ storyId: c.storyId,
+ axes: c.axes,
+ variants: c.variants
+ .map((v) => ({
+ key: v.key,
+ // Width is deliberately omitted for anything containing text.
+ //
+ // 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. Tag measured 32x22 locally and 34x22 on the
+ // CI runner, which made the snapshot contract unsatisfiable: correct on
+ // one machine is always wrong on the other.
+ //
+ // Nothing is lost. These components are auto-layout HUG in Figma, so
+ // the width is derived from content there too; padding, height,
+ // minWidth and the font metrics below are what actually pin the design,
+ // and those are all stable across platforms.
+ size:
+ v.text && v.text.content
+ ? `hug x${v.layout.height}`
+ : v.layout.fluidWidth
+ ? `fluid x${v.layout.height}`
+ : `${v.layout.width}x${v.layout.height}`,
+ padding: [
+ v.layout.paddingTop,
+ v.layout.paddingRight,
+ v.layout.paddingBottom,
+ v.layout.paddingLeft,
+ ].join(' '),
+ minWidth: v.layout.minWidth || 0,
+ text:
+ v.text && v.text.content
+ ? `${v.text.fontSize}/${v.text.lineHeight} w${v.text.fontWeight}`
+ : null,
+ bind: Object.fromEntries(
+ Object.entries(v.bind).sort(([a], [b]) => a.localeCompare(b)),
+ ),
+ values: v.values,
+ }))
+ .sort((a, b) => a.key.localeCompare(b.key)),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ };
+}
+
+async function main() {
+ let staticServer = null;
+ let baseUrl = arg('url', null);
+ const staticDir = arg('static', null);
+ if (!baseUrl) {
+ if (!staticDir) {
+ console.error(
+ 'need --url or --static ',
+ );
+ process.exit(1);
+ }
+ staticServer = await serveStatic(path.resolve(staticDir));
+ baseUrl = staticServer.url;
+ console.log(`serving ${staticDir} at ${baseUrl}`);
+ }
+
+ const browser = await chromium.launch();
+ const page = await browser.newPage({
+ viewport: { width: 1280, height: 720 },
+ });
+
+ // One warm-up load so the preview bundle and the story store exist.
+ await page.goto(
+ `${baseUrl}/iframe.html?id=${CONFIG.components[0].storyId}&viewMode=story`,
+ {
+ waitUntil: 'domcontentloaded',
+ },
+ );
+ await page.waitForFunction(() => !!window.__STORYBOOK_STORY_STORE__, {
+ timeout: 60_000,
+ });
+ await page.waitForSelector('#storybook-root > *', { timeout: 60_000 });
+
+ const components = CONFIG.components.filter((c) => !ONLY || c.name === ONLY);
+ const spec = { generatedFrom: staticDir || baseUrl, components: [] };
+ // Two levels, because they mean different things: `error` is "do not ship this
+ // component", `info` is "this fell back to value matching, which is fine".
+ // Mixing them in one list is why the Tile bug needed a human to notice.
+ const findings = [];
+ const err = (component, message) =>
+ findings.push({ level: 'error', component, message });
+ const info = (component, message) =>
+ findings.push({ level: 'info', component, message });
+ const fontErrors = new Set();
+
+ for (const component of components) {
+ // argTypes are the source of truth for what each axis can be.
+ const argTypes = await page.evaluate(async (storyId) => {
+ const st = await window.__STORYBOOK_STORY_STORE__.loadStory({ storyId });
+ const out = {};
+ for (const [k, v] of Object.entries(st.argTypes || {})) {
+ const t = v?.control?.type ?? v?.control;
+ out[k] = { type: t, options: v.options || null };
+ }
+ return out;
+ }, component.storyId);
+
+ const axes = component.axes.map((name) => {
+ const oneOf = component.oneOf?.[name];
+ if (oneOf) {
+ for (const a of oneOf.args) {
+ if (!argTypes[a])
+ throw new Error(
+ `${component.name}: oneOf "${name}" references unknown arg "${a}"`,
+ );
+ }
+ return {
+ name,
+ oneOf,
+ values: [oneOf.noneLabel ?? 'default', ...oneOf.args],
+ };
+ }
+ if (component.axisValues?.[name])
+ return { name, values: component.axisValues[name] };
+ const at = argTypes[name];
+ if (!at)
+ throw new Error(`${component.name}: arg "${name}" not in argTypes`);
+ if (at.type === 'boolean') return { name, values: [false, true] };
+ if (at.options?.length) return { name, values: at.options };
+ throw new Error(
+ `${component.name}: arg "${name}" has no options and is not boolean`,
+ );
+ });
+
+ const rows = matrix(axes);
+ console.log(
+ `${component.name}: ${rows.length} variants (${axes.map((a) => `${a.name}×${a.values.length}`).join(' ')})`,
+ );
+
+ const variants = [];
+ for (const row of rows) {
+ const args = { ...(component.args || {}), ...row.__args };
+ const url = `${baseUrl}/iframe.html?id=${component.storyId}&viewMode=story&args=${encodeURIComponent(encodeArgs(args))}`;
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#storybook-root > *', { timeout: 30_000 });
+ // Wait for the story to render BEFORE waiting on fonts: a webfont is only
+ // requested once something needs the glyph, so awaiting readiness first
+ // resolves against an empty font set.
+ await page.waitForFunction(() => document.fonts.status === 'loaded', {
+ timeout: 30_000,
+ });
+ // `status === 'loaded'` means "nothing pending", NOT "everything succeeded".
+ // A face that failed sits in `error` and the element silently measures with
+ // fallback metrics — FramedIcon came out 22px instead of 28 because the
+ // RocketChat icon font 404s on the dev server, and 22 looks plausible.
+ const badFonts = await page.evaluate(() =>
+ [...document.fonts]
+ .filter((f) => f.status === 'error')
+ .map((f) => f.family),
+ );
+ if (badFonts.length) fontErrors.add(badFonts.join(', '));
+
+ const measured = await page.evaluate(
+ measureElement,
+ component.rootSelector || null,
+ );
+ if (measured.error) {
+ err(component.name, `${JSON.stringify(row)}: ${measured.error}`);
+ continue;
+ }
+ // Block-level components stretch to the story canvas, so the measured
+ // width is the container, not the component. Decide per variant, not per
+ // component: a vertical Divider is 1px wide while a horizontal one fills.
+ const L = measured.layout;
+ if (
+ component.fluidWidth &&
+ L.containerWidth &&
+ L.width >= L.containerWidth - 1
+ ) {
+ L.fluidWidth = true;
+ delete L.width;
+ }
+ delete L.containerWidth;
+
+ const key = axes
+ .map(
+ (a) =>
+ `${propName(a.name)}=${labelFor(component, a.name, row[a.name])}`,
+ )
+ .join(', ');
+ // Record the actual args sent, not the axis labels — a oneOf axis label
+ // like "medium" is not itself an arg value.
+ variants.push({ key, args: row.__args, ...measured });
+ }
+
+ // An axis that changes nothing on the root element means the differentiator
+ // lives in a child or a pseudo-element, which root-only measurement cannot
+ // see. Without this check the spec looks fine and every variant is a clone.
+ if (variants.length > 1) {
+ const fingerprint = (v) => JSON.stringify([v.values, v.layout, v.text]);
+ const distinct = new Set(variants.map(fingerprint));
+ if (distinct.size === 1) {
+ err(
+ component.name,
+ `ALL ${variants.length} variants measured identically — axes [${component.axes.join(', ')}] ` +
+ `have no effect on ${component.rootSelector}. The differentiator is probably a child node, ` +
+ `a pseudo-element, or an SVG fill, none of which root-only measurement can see.`,
+ );
+ } else if (distinct.size < variants.length / 2) {
+ err(
+ component.name,
+ `only ${distinct.size} distinct renderings across ${variants.length} variants — ` +
+ `at least one axis does not affect ${component.rootSelector}`,
+ );
+ }
+ }
+
+ // Only SOLID paints are emitted. A gradient or image background reads as a
+ // transparent background-color, so the variant would ship blank.
+ const unsupported = variants.filter((v) => v.unsupported);
+ if (unsupported.length) {
+ err(
+ component.name,
+ `${unsupported.length}/${variants.length} variants use a paint this extractor cannot express ` +
+ `(${unsupported[0].unsupported}). Only solid colours are emitted, so these would ship blank.`,
+ );
+ }
+
+ // A root that is transparent with no border and no text is usually a
+ // css-in-js Box wrapper rather than the component: a wrong rootSelector.
+ const blank = variants.filter(
+ (v) =>
+ !v.unsupported &&
+ /,\s*0\)$/.test(v.values.fill) &&
+ v.values.strokeWeight === 0 &&
+ (!v.text || !v.text.content),
+ );
+ if (blank.length === variants.length && variants.length > 0) {
+ err(
+ component.name,
+ `every variant has a transparent fill, no border and no text — ` +
+ `${component.rootSelector} is probably a layout wrapper, not the component`,
+ );
+ }
+
+ // Group identical unbindable sets — 32 copies of the same line is noise.
+ const groups = new Map();
+ for (const v of variants) {
+ if (!v.unbindable.length) continue;
+ const k = v.unbindable.join(',');
+ if (!groups.has(k)) groups.set(k, []);
+ groups.get(k).push(v.key);
+ }
+ for (const [props, keys] of groups) {
+ info(
+ component.name,
+ `no declared custom property for [${props}] on ${keys.length}/${variants.length} variants ` +
+ `(e.g. ${keys[0]}) — falls back to matching a variable by value`,
+ );
+ }
+
+ spec.components.push({
+ name: component.name,
+ storyId: component.storyId,
+ textChild: component.textChild || 'label',
+ axes: Object.fromEntries(
+ axes.map((a) => [
+ propName(a.name),
+ a.values.map((v) => labelFor(component, a.name, v)),
+ ]),
+ ),
+ variants,
+ });
+ }
+
+ await browser.close();
+ if (staticServer) staticServer.server.close();
+
+ spec.findings = findings;
+
+ // Shape contract first: a malformed spec must never reach the apply step.
+ const schemaErrors = validateSpec(spec);
+ if (schemaErrors.length) {
+ console.error(`\nspec failed validation (${schemaErrors.length}):`);
+ for (const e of schemaErrors.slice(0, 20)) console.error(` ${e}`);
+ if (schemaErrors.length > 20) {
+ console.error(` ... and ${schemaErrors.length - 20} more`);
+ }
+ process.exit(1);
+ }
+
+ fs.writeFileSync(OUT, JSON.stringify(spec, null, 2));
+
+ const allVariants = spec.components.flatMap((c) => c.variants);
+ const total = allVariants.length;
+ console.log(
+ `\n${spec.components.length} components, ${total} variants -> ${OUT}`,
+ );
+
+ // Per-property coverage is the number that matters: "variants with zero
+ // unbindable properties" reads as 0% whenever one property has no runtime
+ // token anywhere, which hides that the colors all bound correctly.
+ const PROPS = ['fill', 'stroke', 'textFill', 'strokeWeight', 'radius'];
+ console.log('\ntoken binding coverage by property:');
+ for (const p of PROPS) {
+ const n = allVariants.filter((v) => v.bind[p]).length;
+ const pct = total ? Math.round((n / total) * 100) : 0;
+ console.log(` ${p.padEnd(13)} ${String(n).padStart(4)}/${total} ${pct}%`);
+ }
+
+ if (fontErrors.size) {
+ const msg = `web font(s) failed to load: ${[...fontErrors].join('; ')}`;
+ if (flag('allow-font-errors')) {
+ console.warn(
+ `\nWARNING: ${msg}\nMeasurements for anything using those faces are wrong (fallback metrics).`,
+ );
+ } else {
+ console.error(
+ `\n${msg}\nEvery measurement involving those faces is wrong — the element falls back to\n` +
+ `different glyph metrics and the number still looks plausible.\n\n` +
+ `The dev server does not serve the RocketChat icon font; the built Storybook does.\n` +
+ `Use --static packages/fuselage/storybook-static, which is what CI runs.\n` +
+ `Pass --allow-font-errors only for quick iteration on unaffected components.`,
+ );
+ process.exit(3);
+ }
+ }
+
+ const errors = findings.filter((f) => f.level === 'error');
+ const infos = findings.filter((f) => f.level === 'info');
+
+ if (infos.length) {
+ console.log(`\n${infos.length} note(s) — informational:`);
+ for (const f of infos) console.log(` ${f.component}: ${f.message}`);
+ }
+ if (errors.length) {
+ console.log(`\n${errors.length} ERROR(s) — do not ship these components:`);
+ for (const f of errors) console.log(` ${f.component}: ${f.message}`);
+ }
+
+ // Golden snapshot: a normalised digest committed to the repo, so any change to
+ // a measured value or a token binding lands as a reviewable diff.
+ const snapPath = path.resolve(
+ arg('snapshot', path.join(ROOT_DIR, 'figma-spec.snapshot.json')),
+ );
+ const snapshot = JSON.stringify(snapshotOf(spec), null, 2);
+ if (flag('update-snapshot')) {
+ fs.writeFileSync(snapPath, snapshot);
+ console.log(`\nsnapshot updated -> ${snapPath}`);
+ } else {
+ // Read first and handle ENOENT, rather than exists-then-read: the two-step
+ // form is a filesystem race (CodeQL js/file-system-race) because the file can
+ // change between the check and the read.
+ let raw = null;
+ try {
+ raw = fs.readFileSync(snapPath, 'utf-8');
+ } catch (e) {
+ if (e.code !== 'ENOENT') throw e;
+ }
+ if (raw === null) {
+ fs.writeFileSync(snapPath, snapshot);
+ console.log(`\nsnapshot created -> ${snapPath} (commit this)`);
+ } else {
+ // Compare parsed content, not raw text: the repo's prettier reformats this
+ // file, and a text comparison would then fail forever on whitespace alone.
+ const previous = JSON.stringify(JSON.parse(raw), null, 2);
+ if (previous.trim() === snapshot.trim()) {
+ console.log(`\nsnapshot matches ${path.basename(snapPath)}`);
+ } else {
+ const before = previous.split('\n');
+ const after = snapshot.split('\n');
+ const diffs = [];
+ for (let i = 0; i < Math.max(before.length, after.length); i++) {
+ if (before[i] !== after[i]) {
+ diffs.push(
+ ` line ${i + 1}\n - ${(before[i] || '').trim()}\n + ${(after[i] || '').trim()}`,
+ );
+ }
+ if (diffs.length >= 15) break;
+ }
+ console.error(
+ `\nSNAPSHOT MISMATCH — the spec changed against ${path.basename(snapPath)}.` +
+ `\nReview the diff below. If the change is intended, re-run with --update-snapshot and commit it.\n`,
+ );
+ console.error(diffs.join('\n'));
+ process.exit(2);
+ }
+ }
+ }
+
+ if (errors.length) {
+ console.error(
+ `\nfailing: ${errors.length} component(s) measured wrong. ` +
+ `Fix the config or move them to "skipped" in components.json.`,
+ );
+ process.exit(1);
+ }
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/tools/figma-sync/src/measure.js b/tools/figma-sync/src/measure.js
new file mode 100644
index 0000000000..d0720bb315
--- /dev/null
+++ b/tools/figma-sync/src/measure.js
@@ -0,0 +1,206 @@
+/**
+ * Runs inside the page via page.evaluate — must stay self-contained, no imports
+ * or closure references.
+ *
+ * The interesting part is token resolution. Fuselage stacks three layers:
+ *
+ * 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
+ *
+ * getComputedStyle only ever gives you `#156FF5`, which is why a naive
+ * extractor has to guess a variable by hex and gets it wrong whenever two
+ * tokens share a value. Reading the authored declaration out of the CSSOM and
+ * walking the var() chain to the first *declared* custom property yields the
+ * binding the code actually means.
+ */
+export function measureElement(rootSelector) {
+ const root = document.querySelector('#storybook-root');
+ const el = rootSelector
+ ? root.querySelector(rootSelector)
+ : root.firstElementChild;
+ if (!el)
+ return {
+ error: 'element not found',
+ rootHtml: root ? root.innerHTML.slice(0, 200) : null,
+ };
+
+ // computed: the longhand to read the resolved value from.
+ // authored: every CSS property that could carry the value. Fuselage writes the
+ // shorthands (border-color / border-width / border-radius) on .rcx-button--*,
+ // while the .rcx-box--full reset writes the longhand border-top-color earlier
+ // in the sheet. So neither longhand nor shorthand can be preferred a priori —
+ // whichever was declared LAST wins, which is what the cascade does.
+ const COLOR_PROPS = {
+ fill: { computed: 'background-color', authored: ['background-color'] },
+ stroke: {
+ computed: 'border-top-color',
+ authored: ['border-top-color', 'border-color'],
+ },
+ textFill: { computed: 'color', authored: ['color'] },
+ };
+ const NUM_PROPS = {
+ strokeWeight: {
+ computed: 'border-top-width',
+ authored: ['border-top-width', 'border-width'],
+ },
+ radius: {
+ computed: 'border-top-left-radius',
+ authored: ['border-top-left-radius', 'border-radius'],
+ },
+ };
+
+ /** Authored (pre-resolution) declarations for `el`, base state only. */
+ const authored = (node) => {
+ const wanted = [
+ ...Object.values(COLOR_PROPS),
+ ...Object.values(NUM_PROPS),
+ ].flatMap((p) => p.authored);
+ const found = {};
+ let order = 0;
+ const visit = (rules) => {
+ for (const r of rules) {
+ // Descend into @media / @supports / @layer.
+ if (!r.selectorText && r.cssRules) {
+ visit(r.cssRules);
+ continue;
+ }
+ if (!r.selectorText) continue;
+ const matches = r.selectorText.split(',').some((sel) => {
+ const s = sel.trim();
+ // Skip interaction states — we only describe the base variant here.
+ if (
+ /:(hover|active|focus|focus-visible|focus-within|disabled)\b/.test(
+ s,
+ )
+ )
+ return false;
+ try {
+ return node.matches(s);
+ } catch {
+ return false;
+ }
+ });
+ if (!matches) continue;
+ // Document order, last declaration wins. Specificity is ignored: the
+ // computed value is captured alongside and is the tiebreak/sanity check.
+ order += 1;
+ for (const p of wanted) {
+ const v = r.style.getPropertyValue(p);
+ if (v) found[p] = { value: v.trim(), order };
+ }
+ }
+ };
+ for (const sheet of document.styleSheets) {
+ let rules;
+ try {
+ rules = sheet.cssRules;
+ } catch {
+ continue;
+ } // cross-origin
+ visit(rules);
+ }
+ return found;
+ };
+
+ /** First custom property in the var() chain that is actually declared. */
+ const firstDeclaredVar = (authoredValue, node) => {
+ if (!authoredValue) return null;
+ const cs = getComputedStyle(node);
+ const names = [...authoredValue.matchAll(/var\(\s*(--[\w-]+)/g)].map(
+ (m) => m[1],
+ );
+ for (const n of names) {
+ if (cs.getPropertyValue(n).trim()) return n;
+ }
+ return null;
+ };
+
+ const decls = authored(el);
+ const cs = getComputedStyle(el);
+
+ const bind = {};
+ const values = {};
+ const unbindable = [];
+
+ /** Of the candidate properties, the one declared latest in the cascade. */
+ const lastAuthored = (candidates) => {
+ let best = null;
+ for (const p of candidates) {
+ const d = decls[p];
+ if (d && (!best || d.order > best.order)) best = d;
+ }
+ return best ? best.value : null;
+ };
+
+ for (const [key, prop] of Object.entries(COLOR_PROPS)) {
+ values[key] = cs.getPropertyValue(prop.computed).trim();
+ const cssVar = firstDeclaredVar(lastAuthored(prop.authored), el);
+ if (cssVar) bind[key] = cssVar;
+ else unbindable.push(key);
+ }
+ for (const [key, prop] of Object.entries(NUM_PROPS)) {
+ values[key] = parseFloat(cs.getPropertyValue(prop.computed)) || 0;
+ const cssVar = firstDeclaredVar(lastAuthored(prop.authored), el);
+ if (cssVar) bind[key] = cssVar;
+ else unbindable.push(key);
+ }
+
+ const num = (p) => parseFloat(cs.getPropertyValue(p)) || 0;
+ // A gradient or image background is invisible to a background-color read, so
+ // the element measures as transparent and the emitted component is blank.
+ // Recorded so the extractor can refuse instead of shipping an empty variant.
+ const backgroundImage = cs.getPropertyValue('background-image').trim();
+ const textNode =
+ [...el.querySelectorAll('*')].find(
+ (n) =>
+ n.childNodes.length &&
+ [...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim()),
+ ) || el;
+ const tcs = getComputedStyle(textNode);
+
+ return {
+ tag: el.tagName,
+ // On SVG elements className is an SVGAnimatedString, which stringifies to
+ // "[object SVGAnimatedString]".
+ classes: el.getAttribute('class') || '',
+ layout: {
+ width: Math.round(el.getBoundingClientRect().width),
+ height: Math.round(el.getBoundingClientRect().height),
+ // Recorded so the caller can tell "block element filling the canvas" from
+ // "element that is genuinely this wide". A per-component flag gets this
+ // wrong for components with both orientations, e.g. a vertical Divider.
+ containerWidth: Math.round(
+ (
+ el.parentElement || document.querySelector('#storybook-root')
+ ).getBoundingClientRect().width,
+ ),
+ minWidth: num('min-width'),
+ paddingTop: num('padding-top'),
+ paddingRight: num('padding-right'),
+ paddingBottom: num('padding-bottom'),
+ paddingLeft: num('padding-left'),
+ gap: num('column-gap') || num('gap') || 0,
+ direction:
+ cs.display.includes('flex') && cs.flexDirection.startsWith('column')
+ ? 'VERTICAL'
+ : 'HORIZONTAL',
+ display: cs.display,
+ },
+ text: {
+ content: (textNode.textContent || '').trim().slice(0, 60),
+ fontFamily: tcs.fontFamily.split(',')[0].replace(/["']/g, '').trim(),
+ fontSize: parseFloat(tcs.fontSize),
+ fontWeight: parseInt(tcs.fontWeight, 10),
+ lineHeight: parseFloat(tcs.lineHeight) || parseFloat(tcs.fontSize),
+ align: tcs.textAlign,
+ },
+ bind,
+ values,
+ unbindable,
+ unsupported:
+ backgroundImage && backgroundImage !== 'none'
+ ? `background-image: ${backgroundImage.slice(0, 60)}`
+ : null,
+ };
+}
diff --git a/tools/figma-sync/src/selfcheck.mjs b/tools/figma-sync/src/selfcheck.mjs
new file mode 100644
index 0000000000..c68d9e1614
--- /dev/null
+++ b/tools/figma-sync/src/selfcheck.mjs
@@ -0,0 +1,270 @@
+#!/usr/bin/env node
+/**
+ * Checks that hold without a Storybook or a Figma file, so CI can run them on
+ * every PR. Anything needing a browser lives in extract.mjs; anything needing
+ * Figma lives in the `--twice` idempotency scripts.
+ *
+ * node src/selfcheck.mjs
+ */
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import { execFileSync } from 'child_process';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.join(__dirname, '..');
+
+let failures = 0;
+const check = (name, fn) => {
+ try {
+ fn();
+ console.log(` ok ${name}`);
+ } catch (e) {
+ failures += 1;
+ console.error(` FAIL ${name}\n ${e.message}`);
+ }
+};
+
+console.log('figma-sync selfcheck\n');
+
+const config = JSON.parse(
+ fs.readFileSync(path.join(ROOT, 'components.json'), 'utf-8'),
+);
+
+check('every kept component declares a rootSelector', () => {
+ for (const c of config.components) {
+ assert(
+ c.rootSelector && c.rootSelector.startsWith('.'),
+ `${c.name} has no rootSelector — the story root is a Box wrapper, not the component`,
+ );
+ }
+});
+
+check('every kept component declares at least one axis', () => {
+ for (const c of config.components) {
+ assert(Array.isArray(c.axes) && c.axes.length > 0, `${c.name}: axes`);
+ }
+});
+
+check('storyIds are well-formed and unique', () => {
+ const seen = new Set();
+ for (const c of [...config.components, ...config.skipped]) {
+ assert(c.storyId && c.storyId.includes('--'), `${c.name}: storyId`);
+ assert(!seen.has(c.storyId), `duplicate storyId ${c.storyId}`);
+ seen.add(c.storyId);
+ }
+});
+
+check('no component appears in more than one bucket', () => {
+ const seen = new Map();
+ for (const bucket of ['components', 'skipped', 'outOfScope']) {
+ for (const c of config[bucket] || []) {
+ assert(
+ !seen.has(c.name),
+ `${c.name} is in both ${seen.get(c.name)} and ${bucket}`,
+ );
+ seen.set(c.name, bucket);
+ }
+ }
+});
+
+/**
+ * The gap that let a new component go unnoticed: the extractor only looks at
+ * what components.json lists, so anything added to Fuselage was silently absent
+ * rather than reported. Every component with its own stylesheet and stories must
+ * land in exactly one of three buckets — shipped, tried-and-rejected, or
+ * deliberately out of scope — so adding one forces a decision instead of
+ * defaulting to invisible.
+ */
+check('every component with a stylesheet and stories is triaged', () => {
+ const dir = path.join(ROOT, '..', '..', 'packages/fuselage/src/components');
+ const candidates = fs
+ .readdirSync(dir)
+ .filter((d) => fs.statSync(path.join(dir, d)).isDirectory())
+ .filter((d) => {
+ const files = fs.readdirSync(path.join(dir, d));
+ return (
+ files.includes(`${d}.styles.scss`) && files.includes(`${d}.stories.tsx`)
+ );
+ });
+ const triaged = new Set(
+ ['components', 'skipped', 'outOfScope'].flatMap((b) =>
+ (config[b] || []).map((c) => c.name),
+ ),
+ );
+ const untriaged = candidates.filter((c) => !triaged.has(c));
+ assert(
+ untriaged.length === 0,
+ `${untriaged.length} untriaged: ${untriaged.join(', ')}\n` +
+ ' Run `node src/add-component.mjs ` to get a proposed entry, then\n' +
+ ' either keep it, or record it under "skipped" / "outOfScope" with a reason.',
+ );
+});
+
+check('every out-of-scope component records why', () => {
+ for (const c of config.outOfScope || []) {
+ assert(
+ c.reason && c.reason.length > 20,
+ `${c.name}: outOfScope needs a reason`,
+ );
+ }
+});
+
+check('every skipped component records why', () => {
+ for (const s of config.skipped) {
+ assert(
+ s.reason && s.reason.length > 30,
+ `${s.name}: reason must explain the failure, not just name it`,
+ );
+ }
+});
+
+check('oneOf axes reference args, not axis names', () => {
+ for (const c of config.components) {
+ for (const [axis, def] of Object.entries(c.oneOf || {})) {
+ assert(
+ Array.isArray(def.args) && def.args.length > 0,
+ `${c.name}.${axis}: args`,
+ );
+ assert(
+ !def.args.includes(axis),
+ `${c.name}.${axis}: an axis cannot list itself as one of its args`,
+ );
+ }
+ }
+});
+
+check('plugin/code.js is up to date with src/apply.js', () => {
+ const current = fs.readFileSync(
+ path.join(ROOT, 'plugin', 'code.js'),
+ 'utf-8',
+ );
+ execFileSync(
+ process.execPath,
+ [path.join(__dirname, 'emit-apply.mjs'), '--plugin'],
+ {
+ stdio: 'pipe',
+ },
+ );
+ const regenerated = fs.readFileSync(
+ path.join(ROOT, 'plugin', 'code.js'),
+ 'utf-8',
+ );
+ if (current !== regenerated) {
+ fs.writeFileSync(path.join(ROOT, 'plugin', 'code.js'), regenerated);
+ throw new Error(
+ 'plugin/code.js was stale — it has been regenerated, commit the result. ' +
+ 'It must never be hand-edited; src/apply.js is the source.',
+ );
+ }
+});
+
+// Comments in apply.js deliberately quote the anti-patterns these checks look
+// for, so scanning the raw file gives false positives. Strip them first.
+const codeOnly = (file) =>
+ fs
+ .readFileSync(file, 'utf-8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
+
+const applySrcRaw = fs.readFileSync(path.join(__dirname, 'apply.js'), 'utf-8');
+const applySrc = codeOnly(path.join(__dirname, 'apply.js'));
+
+check('apply.js has no Node or browser globals', () => {
+ for (const banned of [
+ 'require(',
+ 'process.',
+ 'document.',
+ 'window.',
+ 'fetch(',
+ ]) {
+ assert(
+ !applySrc.includes(banned),
+ `apply.js uses "${banned}" — it runs in the Figma plugin sandbox`,
+ );
+ }
+});
+
+check('apply.js never folds current node size into the target', () => {
+ assert(
+ !/Math\.max\([^)]*\b(comp|node)\.(width|height)/.test(applySrc),
+ 'found Math.max(..., node.width) — that makes a re-sync depend on prior state',
+ );
+});
+
+check(
+ 'apply.js seeds paint literals from the measured colour, with alpha',
+ () => {
+ assert(
+ applySrc.includes('opacity: c.a === undefined ? 1 : c.a'),
+ 'paint must carry the measured alpha, or transparent borders render opaque black',
+ );
+ assert(
+ /color:\s*\{\s*r:\s*c\.r,\s*g:\s*c\.g,\s*b:\s*c\.b\s*\}/.test(applySrc),
+ 'paint literal must come from the measured colour, not a hardcoded default',
+ );
+ },
+);
+
+check('apply.js documents why those two rules exist', () => {
+ // Cheap guard against someone "simplifying" the rules away later.
+ assert(
+ /read-and-compare/.test(applySrcRaw) && /alpha/.test(applySrcRaw),
+ 'the invariants must stay documented at the top of apply.js',
+ );
+});
+
+const snapPath = path.join(ROOT, 'figma-spec.snapshot.json');
+check('committed snapshot exists and parses', () => {
+ assert(
+ fs.existsSync(snapPath),
+ 'figma-spec.snapshot.json is missing — run extract once and commit it',
+ );
+ const snap = JSON.parse(fs.readFileSync(snapPath, 'utf-8'));
+ assert(
+ Array.isArray(snap.components) && snap.components.length > 0,
+ 'empty snapshot',
+ );
+});
+
+check('snapshot covers exactly the kept components', () => {
+ const snap = JSON.parse(fs.readFileSync(snapPath, 'utf-8'));
+ const inSnap = snap.components.map((c) => c.name).sort();
+ const kept = config.components.map((c) => c.name).sort();
+ assert.deepStrictEqual(
+ inSnap,
+ kept,
+ `snapshot has [${inSnap}] but components.json keeps [${kept}] — re-run extract with --update-snapshot`,
+ );
+});
+
+check('snapshot variant counts match the axis cross product', () => {
+ const snap = JSON.parse(fs.readFileSync(snapPath, 'utf-8'));
+ for (const c of snap.components) {
+ const expected = Object.values(c.axes).reduce((n, v) => n * v.length, 1);
+ assert.strictEqual(
+ c.variants.length,
+ expected,
+ `${c.name}: ${c.variants.length} variants but axes imply ${expected}`,
+ );
+ }
+});
+
+check('snapshot variant keys use capitalised properties', () => {
+ const snap = JSON.parse(fs.readFileSync(snapPath, 'utf-8'));
+ for (const c of snap.components) {
+ for (const v of c.variants) {
+ for (const part of v.key.split(', ')) {
+ const prop = part.split('=')[0];
+ assert(
+ prop[0] === prop[0].toUpperCase(),
+ `${c.name}/${v.key}: "${prop}" must be capitalised or the sync duplicates the set`,
+ );
+ }
+ }
+ }
+});
+
+console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
+process.exit(failures ? 1 : 0);
diff --git a/yarn.lock b/yarn.lock
index 59198eeb9f..d40493fa2d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5214,6 +5214,16 @@ __metadata:
languageName: unknown
linkType: soft
+"@rocket.chat/figma-sync@workspace:tools/figma-sync":
+ version: 0.0.0-use.local
+ resolution: "@rocket.chat/figma-sync@workspace:tools/figma-sync"
+ dependencies:
+ playwright: "npm:~1.62.0"
+ bin:
+ figma-sync: ./src/extract.mjs
+ languageName: unknown
+ linkType: soft
+
"@rocket.chat/fuselage-forms@workspace:packages/fuselage-forms":
version: 0.0.0-use.local
resolution: "@rocket.chat/fuselage-forms@workspace:packages/fuselage-forms"
@@ -15164,6 +15174,15 @@ __metadata:
languageName: node
linkType: hard
+"playwright-core@npm:1.62.1":
+ version: 1.62.1
+ resolution: "playwright-core@npm:1.62.1"
+ bin:
+ playwright-core: cli.js
+ checksum: 10/2dab88793a6a5cbfa27641664548ded72a4f869d7eab8d3541e070081b5e09595253aa3dbd42eb4e6eca1b03d96a727c60704359b5b309e71ca58b12549a4c06
+ languageName: node
+ linkType: hard
+
"playwright@npm:1.62.0":
version: 1.62.0
resolution: "playwright@npm:1.62.0"
@@ -15179,6 +15198,21 @@ __metadata:
languageName: node
linkType: hard
+"playwright@npm:~1.62.0":
+ version: 1.62.1
+ resolution: "playwright@npm:1.62.1"
+ dependencies:
+ fsevents: "npm:2.3.2"
+ playwright-core: "npm:1.62.1"
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ bin:
+ playwright: cli.js
+ checksum: 10/f99546873ab2545160ad5a0145fef8e2c1d805efd1ba77356f5b0ee3d8688192b3a2af08cd948bece36ca386fdfad889ef7e53b7aaad309940744c0bbccc0a19
+ languageName: node
+ linkType: hard
+
"possible-typed-array-names@npm:^1.0.0":
version: 1.0.0
resolution: "possible-typed-array-names@npm:1.0.0"