diff --git a/.github/workflows/figma-spec.yml b/.github/workflows/figma-spec.yml new file mode 100644 index 0000000000..2e0ef61d47 --- /dev/null +++ b/.github/workflows/figma-spec.yml @@ -0,0 +1,179 @@ +name: Figma spec + +on: + push: + branches: + - main + paths: + - 'packages/fuselage/src/**' + - 'packages/fuselage-tokens/**' + - 'tools/figma-sync/**' + pull_request: + paths: + - 'packages/fuselage/src/**' + - 'packages/fuselage-tokens/**' + - 'tools/figma-sync/**' + workflow_dispatch: + +# The spec is a build artifact, not a Figma write. Applying it has to be triggered +# by a human — not because Figma lacks a headless write path (the remote MCP server +# has one), but because that server is OAuth-only with a client allowlist, and the +# REST API cannot create or modify nodes at all. +# +# What this job does guarantee: the spec is never stale, never malformed, and never +# changes without a reviewable diff. +jobs: + selfcheck: + name: Config and invariant checks + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22.22.3' + cache: yarn + - run: yarn install --immutable + # No browser and no Figma needed: config shape, apply.js invariants, and the + # committed snapshot's internal consistency. + - run: yarn workspace @rocket.chat/figma-sync check + + build-spec: + name: Extract component spec from Storybook + runs-on: ubuntu-latest + needs: selfcheck + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Pinned to match cd.yml — there is no .nvmrc in this repo. + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22.22.3' + cache: yarn + + - run: yarn install --immutable + + # Must go through Turbo, not `yarn workspace ... build-storybook`. turbo.json + # declares `build-storybook: dependsOn ["^build"]`, and calling the workspace + # script directly skips that graph — so workspace packages that Storybook + # resolves as compiled output are never built. It fails on + # `packages/storybook-dark-mode/preset.js` requiring './dist/esm/preset/manager'. + - name: Build Storybook + run: yarn turbo run build-storybook --filter=@rocket.chat/fuselage --cache-dir=".turbo" + + # The Chromium build is pinned by the playwright version in the lockfile, + # which is what makes the measurements reproducible across machines. + - name: Install Chromium + run: node node_modules/playwright/cli.js install --with-deps chromium + + # Exits 1 on an `error` finding (a component that measured wrong) and 2 on a + # snapshot mismatch (a measured value changed). Both should block a merge + # until either the config or the committed snapshot is updated. + # continue-on-error plus an explicit re-raise at the end: we want to comment + # on the PR before failing, because a snapshot mismatch is exactly the moment + # the Figma file has gone stale and somebody needs to re-sync it. + - name: Extract spec + id: extract + continue-on-error: true + run: | + set +e + node tools/figma-sync/src/extract.mjs \ + --static packages/fuselage/storybook-static \ + --out figma-spec.json + echo "code=$?" >> "$GITHUB_OUTPUT" + + # Guarded on the file, not on the exit code: a snapshot mismatch still writes + # a valid spec and is worth checking, an extraction crash writes nothing. + - name: Verify the emitted apply scripts fit the use_figma payload cap + if: hashFiles('figma-spec.json') != '' + run: node tools/figma-sync/src/emit-apply.mjs --spec figma-spec.json --out-dir "$RUNNER_TEMP/apply" + + - name: Summarise + if: always() + run: | + node -e " + const fs = require('fs'); + if (!fs.existsSync('./figma-spec.json')) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, '### Figma spec\nExtraction failed before writing a spec.\n'); + process.exit(0); + } + const s = require('./figma-spec.json'); + const v = s.components.flatMap((c) => c.variants); + const line = (k) => { + const n = v.filter((x) => x.bind[k]).length; + return \`| \${k} | \${n}/\${v.length} | \${Math.round((n / v.length) * 100)}% |\`; + }; + const errors = (s.findings || []).filter((f) => f.level === 'error'); + const infos = (s.findings || []).filter((f) => f.level === 'info'); + const out = [ + '### Figma spec', + \`\${s.components.length} components, \${v.length} variants\`, + '', + '| property | bound by token name | |', + '|---|---|---|', + ...['fill', 'stroke', 'textFill', 'strokeWeight', 'radius'].map(line), + ]; + if (errors.length) { + out.push('', \`### \${errors.length} error(s) — do not ship\`, ''); + out.push(...errors.map((f) => \`- **\${f.component}**: \${f.message}\`)); + } + if (infos.length) { + out.push('', '
' + infos.length + ' informational note(s)', ''); + out.push(...infos.map((f) => \`- \${f.component}: \${f.message}\`)); + out.push('', '
'); + } + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, out.join('\n') + '\n'); + " + + # Nothing otherwise tells anyone that Figma drifted: the snapshot guards the + # repo, not the design file. Without this the file silently falls behind, + # which is how InputBox went missing and FramedIcon kept a wrong width. + - name: Tell the PR that Figma needs a re-sync + if: github.event_name == 'pull_request' && steps.extract.outputs.code == '2' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const marker = ''; + const body = [ + marker, + '### The Figma library is now behind this branch', + '', + 'A measured value or token binding changed, so `figma-spec.snapshot.json` no longer matches. Review the diff in the job log, then:', + '', + '```bash', + 'yarn turbo run build-storybook --filter=@rocket.chat/fuselage', + 'node tools/figma-sync/src/extract.mjs --static packages/fuselage/storybook-static --update-snapshot', + 'node tools/figma-sync/src/emit-apply.mjs', + '```', + '', + 'Commit the updated snapshot, then apply the generated script for each changed component through the Figma MCP or the Fuselage Sync plugin. Until that happens the design file and this branch disagree.', + ].join('\n'); + const { data: comments } = await github.rest.issues.listComments({ + ...context.repo, issue_number: context.issue.number, per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body }); + } + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: figma-spec + path: figma-spec.json + if-no-files-found: ignore + + # Re-raise whatever the extractor decided, so the gate still blocks. + - name: Fail if extraction failed + if: steps.extract.outputs.code != '0' + env: + CODE: ${{ steps.extract.outputs.code }} + run: | + echo "extract.mjs exited $CODE" + exit "$CODE" diff --git a/.prettierignore b/.prettierignore index 29164eddfd..d253de4cc9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,9 @@ bundle-report.html /.yarn /README.md *.api.md + +# Generated from tools/figma-sync/src/apply.js — see that file, not this one. +tools/figma-sync/plugin/code.js + +# Build artifact from the figma-sync extractor. +tools/figma-sync/figma-spec.json diff --git a/tools/figma-sync/.gitignore b/tools/figma-sync/.gitignore new file mode 100644 index 0000000000..0f6e1f148d --- /dev/null +++ b/tools/figma-sync/.gitignore @@ -0,0 +1,2 @@ +figma-spec.json +.apply/ diff --git a/tools/figma-sync/README.md b/tools/figma-sync/README.md new file mode 100644 index 0000000000..2a3465cd41 --- /dev/null +++ b/tools/figma-sync/README.md @@ -0,0 +1,350 @@ +# @rocket.chat/figma-sync + +Generates Fuselage's Figma component library from Storybook. Replaces +story.to.design with something that understands our token architecture. + +## The short version + +**Every script here is generic and you never edit one.** The only per-component +thing is a single entry in `components.json`. + +When a component is added to Fuselage, CI fails with `untriaged: ` until +you put it in one of three buckets. That is the whole maintenance burden: one +entry per component, once. + +``` + ┌─ generic, never edited ────────────────────────┐ +components.json ───► │ extract.mjs ──► measure.js (runs in the page) │ ───► figma-spec.json + ▲ one entry │ │ │ (build artifact) + │ per component │ └─ writes figma-spec.snapshot.json │ │ + │ │ │ ▼ + │ │ emit-apply.mjs ──► apply.js (runs in Figma) │ ◄───────────┘ + │ │ selfcheck.mjs (no browser, no Figma) │ + └─ add-component ── │ add-component.mjs │ + proposes it └────────────────────────────────────────────────┘ +``` + +## Which script do I run? + +| Script | Needs | You run it | What it is | +| ------------------- | --------- | ------------------------------------------ | ------------------------------------- | +| `add-component.mjs` | Storybook | once, per new component | proposes a `components.json` entry | +| `extract.mjs` | Storybook | after changing a component, and CI runs it | measures everything, writes the spec | +| `selfcheck.mjs` | nothing | CI, every PR (`yarn check`) | config and invariant checks | +| `emit-apply.mjs` | a spec | when you want to push to Figma | generates the apply scripts | +| `measure.js` | — | never directly | library, runs inside the browser page | +| `apply.js` | — | never directly | library, runs inside Figma | + +`measure.js` and `apply.js` are the two that do the real work, and neither is +executable on its own: one is injected into the page by Playwright, the other into +Figma by the plugin or the MCP call. + +## Lifecycle of a new component + +1. Someone adds `Foo` to Fuselage. `yarn check` starts failing: `1 untriaged: Foo`. +2. `node src/add-component.mjs Foo --url http://localhost:6006` prints a proposed + entry — story id, root class, candidate axes. +3. You review the judgement calls it refuses to make (which axes are visual, + whether booleans are mutually exclusive, what sample args it needs) and put the + entry in `components.json`. +4. `node src/extract.mjs --url http://localhost:6006 --only Foo`. The checks decide: + - clean → keep it, then re-run without `--only` and `--update-snapshot`, commit + - `error`-level finding → move it to `skipped` with the reason. Not a failure of + the config; three of the known gaps only show up at this point. +5. To get it into Figma: `node src/emit-apply.mjs`, then run the generated script + for that component through the Figma MCP (or the plugin). + +Step 4 is the one that matters. The tool never decides that a component is fine — +it either measures it or refuses. + +## What is _not_ generic + +The measurement generalises to any component whose appearance lives on one element +as solid colours. Three gaps decide whether a new component is acceptable, and the +checks name which one you hit: + +- **Child nodes.** ProgressBar's colour is on the child bar; Throbber's dots are + children. The root measures identically across variants. +- **Non-solid paints.** Skeleton is an animated gradient, Tile's elevation is a + `box-shadow`. Neither survives a `background-color` read. +- **SVG and transforms.** StatusBullet's colour is a `path` fill; Chevron's + direction is `transform: rotate()`. + +All the logic lives in the extractor. The apply step makes no design decisions — +every value it writes comes from the spec. **If output looks wrong, fix the +extractor, not the apply step.** + +## Who can apply the spec + +Two ways, and the choice decides whether `plugin/` is worth keeping. + +| | Triggered by | Needs Figma open | Maintains a plugin | +| ----------------------- | ----------------------------- | ---------------- | ------------------ | +| Figma MCP (`use_figma`) | a dev in Claude Code / Cursor | no | no | +| `plugin/` | anyone, including designers | yes | yes | + +The remote Figma MCP server writes to the canvas server-side, with no desktop app +and no open editor — the variants currently in the file were applied that way, +not through the plugin. So if only engineers ever trigger a sync, the plugin is +redundant. + +**Neither can run in CI**, and the reason is authentication, not headlessness: + +- The MCP server is OAuth-only. Figma support, verbatim: "Figma's MCP server does + not support authentication using personal access tokens and this cannot be + enabled." It also enforces a client allowlist — only clients in the Figma MCP + Catalog (VS Code, Cursor, Claude Code…) may connect, which a GitHub Action is not. +- The REST API cannot create or modify nodes at all. It covers file JSON, comments, + variables, dev resources, webhooks and analytics — not canvas content. + +So CI generates and publishes the spec; a human applies it. The one thing that +_could_ be fully automated is the token collections, via `POST /v1/files/:key/variables` +with a plan access token (org-scoped, CI-oriented) — but that endpoint needs an +Enterprise plan. + +## Why not just read computed styles + +`getComputedStyle` resolves `var()` away, leaving only a hex. An extractor that +sees `#156FF5` has to guess which variable to bind, and guesses wrong whenever +two tokens share a value — Fuselage has 11 such collisions. + +The CSSOM still holds the _authored_ declaration, and Fuselage stacks three +layers: + +```css +var(--rcx-button-primary-background-color, /* override hook, never declared */ + var(--rcx-color-button-background-primary-default, /* component token, DECLARED */ + var(--rcx-color-blue-500, #156FF5))) /* primitive, never declared */ +``` + +Walking that chain to the first **declared** custom property yields the binding +the code actually means. That is the whole trick, and it is why this beats a +generic DOM-to-Figma converter for a design system. + +Two consequences worth knowing: + +- Primitives are compile-time only. They never exist as runtime custom + properties, so they are never a binding target — only a fallback value. +- Not everything is tokenized. Button's `warning`, `secondary-warning` and + `secondary-success` variants have no `button-*` custom property, and + `border-width` / `border-radius` have none anywhere. Those fall back to + matching a variable by value, which the extractor reports as a warning. + +## Usage + +**`--static` is the authoritative source.** The dev server does not serve the +RocketChat icon font, so anything using a glyph measures with fallback metrics — +FramedIcon came out 22px wide instead of 28. The extractor now refuses to run +(exit 3) when a web font is in `error` state rather than emitting plausible-looking +wrong numbers. The committed snapshot must always come from `--static`. + +```bash +yarn turbo run build-storybook --filter=@rocket.chat/fuselage +node src/extract.mjs --static ../../packages/fuselage/storybook-static +``` + +The dev server is fine for iterating on components that use no icon font; pass +`--allow-font-errors` to proceed anyway, and do not update the snapshot from it: + +```bash +node src/extract.mjs --url http://localhost:6006 --only Tag --allow-font-errors +``` + +Flags: `--url`, `--static`, `--out`, `--config`, `--only `. + +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"