figma-console-mcp 1.32.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/cloudflare/core/accessibility-tools.js +55 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +2 -0
- package/dist/cloudflare/core/deep-component-tools.js +1 -1
- package/dist/cloudflare/core/design-code-tools.js +60 -17
- package/dist/cloudflare/core/design-system-manifest.js +19 -14
- package/dist/cloudflare/core/design-system-tools.js +43 -34
- package/dist/cloudflare/core/diagnose-tool.js +3 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
- package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
- package/dist/cloudflare/core/figma-api.js +118 -54
- package/dist/cloudflare/core/figma-tools.js +180 -64
- package/dist/cloudflare/core/port-discovery.js +516 -22
- package/dist/cloudflare/core/tokens/alias-resolver.js +62 -5
- package/dist/cloudflare/core/tokens/figma-converter.js +107 -13
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +76 -21
- package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/cloudflare/core/tokens/index.js +1 -1
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
- package/dist/cloudflare/core/tokens-tools.js +78 -10
- package/dist/cloudflare/core/version-tools.js +44 -3
- package/dist/cloudflare/core/websocket-connector.js +2 -0
- package/dist/cloudflare/core/websocket-server.js +59 -8
- package/dist/cloudflare/core/write-tools.js +125 -60
- package/dist/cloudflare/index.js +3 -3
- package/dist/core/deep-component-tools.js +1 -1
- package/dist/core/deep-component-tools.js.map +1 -1
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +60 -17
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/design-system-manifest.d.ts +1 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -1
- package/dist/core/design-system-manifest.js +19 -14
- package/dist/core/design-system-manifest.js.map +1 -1
- package/dist/core/design-system-tools.d.ts.map +1 -1
- package/dist/core/design-system-tools.js +43 -34
- package/dist/core/design-system-tools.js.map +1 -1
- package/dist/core/diagnose-tool.d.ts +2 -0
- package/dist/core/diagnose-tool.d.ts.map +1 -1
- package/dist/core/diagnose-tool.js +3 -0
- package/dist/core/diagnose-tool.js.map +1 -1
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
- package/dist/core/enrichment/enrichment-service.js +11 -5
- package/dist/core/enrichment/enrichment-service.js.map +1 -1
- package/dist/core/enrichment/style-resolver.d.ts +7 -2
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
- package/dist/core/enrichment/style-resolver.js +38 -18
- package/dist/core/enrichment/style-resolver.js.map +1 -1
- package/dist/core/figma-api.d.ts +18 -9
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +118 -54
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/figma-tools.d.ts.map +1 -1
- package/dist/core/figma-tools.js +180 -64
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/port-discovery.d.ts +40 -0
- package/dist/core/port-discovery.d.ts.map +1 -1
- package/dist/core/port-discovery.js +404 -31
- package/dist/core/port-discovery.js.map +1 -1
- package/dist/core/tokens/alias-resolver.d.ts +29 -3
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
- package/dist/core/tokens/alias-resolver.js +62 -5
- package/dist/core/tokens/alias-resolver.js.map +1 -1
- package/dist/core/tokens/figma-converter.d.ts +21 -2
- package/dist/core/tokens/figma-converter.d.ts.map +1 -1
- package/dist/core/tokens/figma-converter.js +107 -13
- package/dist/core/tokens/figma-converter.js.map +1 -1
- package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
- package/dist/core/tokens/formatters/css-vars.js +21 -12
- package/dist/core/tokens/formatters/css-vars.js.map +1 -1
- package/dist/core/tokens/formatters/dtcg.js +76 -21
- package/dist/core/tokens/formatters/dtcg.js.map +1 -1
- package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
- package/dist/core/tokens/formatters/scss.js +19 -13
- package/dist/core/tokens/formatters/scss.js.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
- package/dist/core/tokens/index.d.ts +1 -1
- package/dist/core/tokens/index.d.ts.map +1 -1
- package/dist/core/tokens/index.js +1 -1
- package/dist/core/tokens/index.js.map +1 -1
- package/dist/core/tokens/parsers/dtcg.js +32 -5
- package/dist/core/tokens/parsers/dtcg.js.map +1 -1
- package/dist/core/tokens/types.d.ts +28 -1
- package/dist/core/tokens/types.d.ts.map +1 -1
- package/dist/core/tokens/types.js.map +1 -1
- package/dist/core/tokens-tools.d.ts +44 -0
- package/dist/core/tokens-tools.d.ts.map +1 -1
- package/dist/core/tokens-tools.js +78 -10
- package/dist/core/tokens-tools.js.map +1 -1
- package/dist/core/version-tools.d.ts.map +1 -1
- package/dist/core/version-tools.js +44 -3
- package/dist/core/version-tools.js.map +1 -1
- package/dist/core/websocket-server.d.ts +4 -0
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +59 -8
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/core/write-tools.d.ts.map +1 -1
- package/dist/core/write-tools.js +82 -47
- package/dist/core/write-tools.js.map +1 -1
- package/dist/local.d.ts +0 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +241 -58
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +166 -29
- package/figma-desktop-bridge/ui.html +550 -297
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
> **Your design system as an API.** Model Context Protocol server that bridges design and development—giving AI assistants complete access to Figma for **extraction**, **creation**, **debugging**, and **bidirectional token sync**.
|
|
10
10
|
|
|
11
|
-
> **🆕
|
|
11
|
+
> **🆕 Self-healing connection + 33-fix audit (v1.33.0):** The Desktop Bridge now tells the truth and heals itself — the status pill derives from live connection state (it used to glow green with zero MCP servers connected), `/health` auto-discovery reconnects restarted servers without the manual ritual, and a version handshake banners the plugin when it needs a re-import. Underneath: a 33-fix full-codebase audit — lossless DTCG multi-mode round-trips, cross-collection alias resolution, branch-URL correctness across REST tools, cache-poisoning and CSWSH fixes. **Re-import the plugin manifest one more time** — the new handshake makes it the last one you have to discover on your own. [See what's new →](CHANGELOG.md#1330---2026-07-02)
|
|
12
12
|
|
|
13
13
|
## What is this?
|
|
14
14
|
|
|
@@ -808,9 +808,11 @@ The architecture supports adding new apps with minimal boilerplate — each app
|
|
|
808
808
|
|
|
809
809
|
## 🛤️ Roadmap
|
|
810
810
|
|
|
811
|
-
**Current Status:** v1.
|
|
811
|
+
**Current Status:** v1.33.0 (Stable) - Production-ready. Latest: connection UX overhaul (honest status pill derived from live connection state, `/health` auto-discovery with self-healing reconnect, version handshake with in-plugin re-import banner) + a 33-fix full-codebase audit (lossless DTCG multi-mode round-trips, cross-collection alias resolution, branch-URL correctness across REST tools, cache-poisoning and CSWSH fixes, bridge-first screenshots). Built on WCAG-accurate accessibility auditing (line height below 1.5× is no longer mis-flagged as a failure; readability hints decoupled from conformance checks and scoped to multi-line text; code-side WCAG 1.4.12 check), a self-healing Desktop Bridge connection (zombie-process reaper + auto-reconnect watchdog — fixes the recurring "not connected until restart" bug), native variable binding on fills/strokes + typography control in the write tools, shared-library inspection (key-based component resolution + library variable read/import without Enterprise plan), 10-format token export pipeline (DTCG, CSS, Tailwind v4, Tailwind v3, SCSS, TS module, JSON flat/nested, Style Dictionary v3, Tokens Studio), bidirectional Figma↔code token sync, version history & time-series awareness, FigJam + Slides support, Cloud Write Relay, Design System Kit, WebSocket-only connectivity, smart multi-file tracking, **106 tools** (Local) / **95 tools** (Cloud) / **9 tools** (Remote read-only), Comments API, cross-MCP identity disambiguation, and MCP Apps.
|
|
812
812
|
|
|
813
813
|
**Recent Releases:**
|
|
814
|
+
- [x] **v1.33.0** - Connection UX overhaul + full-codebase audit. The plugin's status pill now derives from live connection state instead of Figma's variables loading (it used to glow green with zero MCP servers connected); HTTP `/health` auto-discovery reconnects restarted servers automatically (including one-dead-among-live, previously a permanent dead end); a version handshake banners the plugin UI when a re-import is needed and surfaces the mismatch in `figma_get_status`/`figma_diagnose`; cloud pairing config survives plugin reopen and its status line is derived + labeled (no more orphaned "Disconnected" under a green pill); all plugin copy is designer-language. The audit fixed 33 verified issues: lossless DTCG multi-mode round-trips, set-qualified cross-collection aliases, TIMING/EASING mapped to DTCG `duration`/`cubicBezier`, two cache-poisoning bugs (the "search returns 0 components" reports), a CSWSH origin bypass (`startsWith` → exact match), post-sleep reaper kill-safety (plus a shell-free `/health` probe with `os.devNull` so Windows curl can't false-negative a healthy sibling), branch-URL correctness across REST tools, and bridge-first `figma_take_screenshot`. `figma_arrange_component_set` now rearranges variants in place so placed instances survive. No new tools; **plugin re-import required** (`code.js` + `ui.html` changed — and the new handshake makes this the last one you have to discover on your own). 1236 tests passing (33 new).
|
|
815
|
+
- [x] **v1.32.1** - Documentation-generator fix reported by Robin Di Capua: `figma_generate_component_doc` documented **colors** as raw hex (with `—` in the Figma Variable column) even when fills/strokes were bound to variables, while spacing tokens documented correctly. Two root causes — an id→name lookup that read the wrong keys (`.id`/`.name` instead of `variableId`/`variableName`), and variable names only ever being sourced from the Enterprise-only REST `/variables/local` endpoint (403 elsewhere). The generator now resolves names via the Desktop Bridge Plugin API (works on every plan) and threads them through the States, Color Tokens, and Spacing tables, so real token names like `color/content/default` and `spacing/1` appear. No new tools, no arg-shape changes, no plugin re-import required. 1203 tests passing.
|
|
814
816
|
- [x] **v1.32.0** - Accessibility-audit correctness fix reported by Isabella (a11y collaborator): `figma_lint_design` was flagging line height below 1.5× as an accessibility failure on hundreds of components. That misreads **WCAG 1.4.12 Text Spacing**, which requires content to *support* user spacing overrides without loss — not that designs *ship* at 1.5× — so a sub-1.5 line height is not a conformance failure. Line/paragraph-spacing checks are now scoped to multi-line text (single-line labels and buttons exempt); readability hints (`text-size`, `line-height`, `letter-spacing`, `paragraph-spacing`) are decoupled from the `wcag` group into an opt-in `best-practice` group, so the default audit (`['wcag','design-system','layout']`) and `rules: ['wcag']` return genuine conformance only; and a new code-side `text-spacing-support` advisory in `figma_scan_code_accessibility` flags fixed-px typography — where 1.4.12/1.4.4 are actually verifiable. No new tools, no arg-shape changes; **plugin re-import required** to pick up the new audit behavior (bridge protocol unchanged, so an un-updated plugin stays compatible). 1196 tests passing.
|
|
815
817
|
- [x] **v1.31.0** - Fixes the most-reported reliability bug: the Desktop Bridge connection dropping and staying down until you closed the plugin, restarted your MCP client, or killed ports by hand. Root cause was **zombie MCP server processes** squatting the WebSocket port range (9223–9232) after a bad shutdown. The reaper now escalates `SIGTERM` → `SIGKILL` (a hung server that ignores graceful shutdown can no longer survive), sweeps the range every 5 minutes via an `unref`'d periodic reaper, and a shutdown backstop prevents a server from zombifying in the first place. The redesigned Desktop Bridge plugin adds an auto-reconnect watchdog (re-probes every ~12s while disconnected), a context-aware **Pause / Resume / Reconnect** button, and a live server-count badge. No new tools; **plugin re-import required** (bridge `ui.html` + `code.js` changed). 1190 tests passing, including an integration test that spawns a real `SIGTERM`-ignoring process and asserts the reaper kills it.
|
|
816
818
|
- [x] **v1.30.0** - Native variable binding + typography in the structured write tools, closing the Plugin API gaps that used to force raw `figma_execute`. `figma_set_fills` / `figma_set_strokes` accept a `variableId` to bind a fill/stroke to a color variable via `setBoundVariableForPaint` (any plan, via the bridge). `figma_set_text` gains `fontFamily` / `fontStyle` with space-insensitive normalization (`SemiBold` → `Semi Bold`) and graceful `Regular` fallback. `figma_instantiate_component` pre-loads instance text fonts before applying overrides (fixes silently-skipped text overrides on non-Regular weights) and returns a `warnings` array for failed overrides. Also fixes a mixed-font crash in `figma_set_text` and a `ui.html` relay that was dropping new message fields. No new tools; **plugin re-import required** (bridge `ui.html` + `code.js` changed). Validated live; 1185 tests passing.
|
|
@@ -838,7 +840,6 @@ The architecture supports adding new apps with minimal boilerplate — each app
|
|
|
838
840
|
- [ ] **Token sync — parsers + import-side apply expansion** - Parsers for non-DTCG input (Tokens Studio, CSS vars, Tailwind v4, Tailwind v3 config, SCSS, Style Dictionary v3, JSON flat/nested). Plus `toCreate` apply orchestration, `toDelete` for `replace` strategy, alias-target updates, and cross-library variable resolution via `getVariableByIdAsync` so cross-library aliases render as real `var(--target)` references instead of comments.
|
|
839
841
|
- [ ] **Component template library** - Common UI pattern generation
|
|
840
842
|
- [ ] **Visual regression testing** - Screenshot diff capabilities
|
|
841
|
-
- [ ] **Design linting** - Automated compliance and accessibility checks
|
|
842
843
|
|
|
843
844
|
**📖 [Full Roadmap](docs/ROADMAP.md)**
|
|
844
845
|
|
|
@@ -242,11 +242,58 @@ function formatAxeResults(axeResults) {
|
|
|
242
242
|
inapplicable: axeResults.inapplicable?.length || 0,
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Heuristic WCAG 1.4.12 (Text Spacing) / 1.4.4 (Resize Text) signal for CODE.
|
|
247
|
+
*
|
|
248
|
+
* This is where text-spacing override support actually belongs: 1.4.12 is about whether
|
|
249
|
+
* content survives a USER overriding line/letter/word/paragraph spacing — it cannot be
|
|
250
|
+
* proven from a design's spacing value, only from the code. axe-core can't evaluate it
|
|
251
|
+
* (it needs layout), but the common, checkable risk indicator is fixed-px typography:
|
|
252
|
+
* line-height/font-size locked in px don't respond to spacing overrides or text-only zoom,
|
|
253
|
+
* whereas unitless line-height and rem/em sizing do. Surfaces fixed-px usage as an advisory
|
|
254
|
+
* (info) finding so teams verify reflow — without red-flagging sub-1.5 line heights, which
|
|
255
|
+
* are not failures.
|
|
256
|
+
*/
|
|
257
|
+
function scanTextSpacingSupport(html) {
|
|
258
|
+
// Collect CSS from <style> blocks and inline style="" attributes
|
|
259
|
+
let css = "";
|
|
260
|
+
const styleBlocks = html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) || [];
|
|
261
|
+
for (const block of styleBlocks)
|
|
262
|
+
css += block.replace(/<\/?style[^>]*>/gi, "") + "\n";
|
|
263
|
+
const inlineStyles = html.match(/style=["']([^"']*)["']/gi) || [];
|
|
264
|
+
for (const s of inlineStyles)
|
|
265
|
+
css += s.replace(/^style=["']|["']$/gi, "") + ";\n";
|
|
266
|
+
if (!css.trim())
|
|
267
|
+
return null;
|
|
268
|
+
// line-height: <n>px → won't grow when a user overrides text spacing (1.4.12 risk)
|
|
269
|
+
const pxLineHeights = css.match(/line-height\s*:\s*[\d.]+px/gi) || [];
|
|
270
|
+
// font-size: <n>px → won't scale on text-only zoom / 200% resize (1.4.4 risk)
|
|
271
|
+
const pxFontSizes = css.match(/font-size\s*:\s*[\d.]+px/gi) || [];
|
|
272
|
+
if (pxLineHeights.length === 0 && pxFontSizes.length === 0)
|
|
273
|
+
return null;
|
|
274
|
+
const samples = [
|
|
275
|
+
...pxLineHeights.slice(0, 5).map((m) => m.trim()),
|
|
276
|
+
...pxFontSizes.slice(0, 5).map((m) => m.trim()),
|
|
277
|
+
];
|
|
278
|
+
return {
|
|
279
|
+
rule: "text-spacing-support",
|
|
280
|
+
severity: "info",
|
|
281
|
+
count: pxLineHeights.length + pxFontSizes.length,
|
|
282
|
+
description: `Typography uses fixed px units (${pxLineHeights.length} px line-height, ${pxFontSizes.length} px font-size). ` +
|
|
283
|
+
"This is where WCAG 1.4.12 (Text Spacing) and 1.4.4 (Resize Text) are actually verified — not on the design side. " +
|
|
284
|
+
"Prefer unitless line-height (e.g. line-height: 1.4) and rem/em font sizes so user spacing overrides and text-only zoom do not clip or overlap content. " +
|
|
285
|
+
"Advisory only: a sub-1.5 line height is NOT a failure — confirm content still reflows when the 1.4.12 text-spacing overrides are applied.",
|
|
286
|
+
wcagTags: ["wcag22aa", "wcag1412", "wcag144"],
|
|
287
|
+
helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html",
|
|
288
|
+
samples,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
245
291
|
export function registerAccessibilityTools(server) {
|
|
246
292
|
server.tool("figma_scan_code_accessibility", "Scan HTML code for accessibility violations using axe-core (Deque). " +
|
|
247
293
|
"Runs structural/semantic checks via JSDOM: ARIA attributes, roles, labels, alt text, " +
|
|
248
294
|
"form labels, heading order, landmarks, semantic HTML, tabindex, duplicate IDs, lang attribute, and ~50 more rules. " +
|
|
249
295
|
"Visual checks (color contrast, focus visibility) are disabled in this mode — use figma_lint_design for visual a11y on the design side. " +
|
|
296
|
+
"Also surfaces a WCAG 1.4.12 (Text Spacing) / 1.4.4 (Resize Text) advisory when typography is locked to fixed px units — the code side is where text-spacing override support is actually verified (a sub-1.5 line height in a design is not itself a failure). " +
|
|
250
297
|
"Together, these two tools provide full-spectrum accessibility coverage across design and code. " +
|
|
251
298
|
"Pass component HTML directly or use with figma_check_design_parity for design-to-code a11y comparison. " +
|
|
252
299
|
"No Figma connection required — this is a standalone code analysis tool.", {
|
|
@@ -264,6 +311,14 @@ export function registerAccessibilityTools(server) {
|
|
|
264
311
|
context: context || undefined,
|
|
265
312
|
});
|
|
266
313
|
const formatted = formatAxeResults(axeResults);
|
|
314
|
+
// Supplementary WCAG 1.4.12 / 1.4.4 advisory — axe can't see layout/overrides,
|
|
315
|
+
// and this (not the design side) is where text-spacing support is verifiable.
|
|
316
|
+
const textSpacing = scanTextSpacingSupport(html);
|
|
317
|
+
if (textSpacing) {
|
|
318
|
+
formatted.categories.push(textSpacing);
|
|
319
|
+
formatted.summary.info += textSpacing.count;
|
|
320
|
+
formatted.summary.total += textSpacing.count;
|
|
321
|
+
}
|
|
267
322
|
// Optionally strip pass/incomplete counts to save tokens
|
|
268
323
|
if (!includePassingRules) {
|
|
269
324
|
delete formatted.passes;
|
|
@@ -222,6 +222,8 @@ export class CloudWebSocketConnector {
|
|
|
222
222
|
params.fontWeight = options.fontWeight;
|
|
223
223
|
if (options.fontFamily)
|
|
224
224
|
params.fontFamily = options.fontFamily;
|
|
225
|
+
if (options.fontStyle)
|
|
226
|
+
params.fontStyle = options.fontStyle;
|
|
225
227
|
}
|
|
226
228
|
return this.sendCommand('SET_TEXT_CONTENT', params);
|
|
227
229
|
}
|
|
@@ -78,7 +78,7 @@ export function registerDeepComponentTools(server, getDesktopConnector) {
|
|
|
78
78
|
// -----------------------------------------------------------------------
|
|
79
79
|
// Tool: figma_analyze_component_set
|
|
80
80
|
// -----------------------------------------------------------------------
|
|
81
|
-
server.tool("figma_analyze_component_set", "Analyze a Figma COMPONENT_SET to extract variant state machine and cross-variant diffs for code generation. Returns: (1) variant axes (size, state) with all values, (2) CSS pseudo-class mappings for interaction states (hover→:hover, focus→:focus-visible, disabled→:disabled, error→[aria-invalid]), (3) visual diff from default state per variant (only changed properties — fill token, stroke token, stroke weight, text color, opacity, effects, visibility), (4) component property definitions mapped to code props (BOOLEAN→boolean, TEXT→string, INSTANCE_SWAP→slot/ReactNode). Use this on the parent COMPONENT_SET node, not individual variants. Requires Desktop Bridge plugin.", {
|
|
81
|
+
server.tool("figma_analyze_component_set", "Analyze a Figma COMPONENT_SET to extract variant state machine and cross-variant diffs for code generation. Returns: (1) variant axes (size, state) with all values, (2) CSS pseudo-class mappings for interaction states (hover→:hover, focus→:focus-visible, disabled→:disabled, error→[aria-invalid]), (3) visual diff from default state per variant (only changed properties — fill token, stroke token, stroke weight, text color, opacity, effects, visibility), (4) component property definitions mapped to code props (BOOLEAN→boolean, TEXT→string, INSTANCE_SWAP→slot/ReactNode), and (5) slots — Figma SLOT properties (name, preferredValues = the components a slot accepts, and limitViolations) to implement as named slots / children props (React {children} or named ReactNode; Web Components <slot name>). Use this on the parent COMPONENT_SET node, not individual variants. Requires Desktop Bridge plugin.", {
|
|
82
82
|
nodeId: z
|
|
83
83
|
.string()
|
|
84
84
|
.describe("COMPONENT_SET node ID (the parent of all variants, e.g., '214:274')"),
|
|
@@ -477,7 +477,7 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
|
477
477
|
/**
|
|
478
478
|
* Collect spacing tokens with their bound variable names.
|
|
479
479
|
*/
|
|
480
|
-
function collectSpacingTokens(node) {
|
|
480
|
+
function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
481
481
|
const tokens = [];
|
|
482
482
|
const boundVars = node.boundVariables || {};
|
|
483
483
|
const spacingProps = [
|
|
@@ -493,7 +493,11 @@ function collectSpacingTokens(node) {
|
|
|
493
493
|
const value = node[key];
|
|
494
494
|
if (value !== undefined && value !== null) {
|
|
495
495
|
const varBinding = boundVars[key];
|
|
496
|
-
|
|
496
|
+
// Bound spacing variables expose their id as boundVariables[key].id.
|
|
497
|
+
// Resolve it to a friendly token name (e.g. `spacing/1`) when the caller
|
|
498
|
+
// supplied a name map; fall back to the raw id so the binding stays visible.
|
|
499
|
+
const varId = typeof varBinding?.id === "string" ? varBinding.id : undefined;
|
|
500
|
+
const varName = varId ? varNameMap.get(varId) || varId : undefined;
|
|
497
501
|
tokens.push({
|
|
498
502
|
property: label,
|
|
499
503
|
value,
|
|
@@ -843,20 +847,26 @@ function compareTokens(enrichedData, codeSpec, discrepancies) {
|
|
|
843
847
|
});
|
|
844
848
|
}
|
|
845
849
|
}
|
|
846
|
-
// Cross-reference design variables with code tokens
|
|
850
|
+
// Cross-reference design variables with code tokens.
|
|
851
|
+
// enrichment entries key off variableName (NOT name); reading `.name` here left
|
|
852
|
+
// every comparison undefined (and would throw on .toLowerCase()). Use variableName
|
|
853
|
+
// and skip any entries that never resolved to a real token name.
|
|
847
854
|
if (enrichedData.variables_used && ct.usedTokens) {
|
|
848
|
-
const
|
|
855
|
+
const designTokens = enrichedData.variables_used
|
|
856
|
+
.map((v) => v.variableName)
|
|
857
|
+
.filter((n) => typeof n === "string" && n.length > 0);
|
|
858
|
+
const designTokenNames = designTokens.map((n) => n.toLowerCase());
|
|
849
859
|
const codeTokenNames = ct.usedTokens.map((t) => t.toLowerCase());
|
|
850
|
-
for (const
|
|
851
|
-
const normalizedName =
|
|
860
|
+
for (const tokenName of designTokens) {
|
|
861
|
+
const normalizedName = tokenName.toLowerCase();
|
|
852
862
|
if (!codeTokenNames.some((ct) => ct.includes(normalizedName) || normalizedName.includes(ct))) {
|
|
853
863
|
discrepancies.push({
|
|
854
864
|
category: "tokens",
|
|
855
|
-
property: `token:${
|
|
865
|
+
property: `token:${tokenName}`,
|
|
856
866
|
severity: "minor",
|
|
857
|
-
designValue:
|
|
867
|
+
designValue: tokenName,
|
|
858
868
|
codeValue: null,
|
|
859
|
-
message: `Design uses token "${
|
|
869
|
+
message: `Design uses token "${tokenName}" but code doesn't reference it`,
|
|
860
870
|
suggestion: `Add token reference in code`,
|
|
861
871
|
});
|
|
862
872
|
}
|
|
@@ -1680,13 +1690,16 @@ function deduplicateColors(colors) {
|
|
|
1680
1690
|
}
|
|
1681
1691
|
return Array.from(seen.values());
|
|
1682
1692
|
}
|
|
1683
|
-
function generateVisualSpecsSection(node, enrichedData, variantData) {
|
|
1693
|
+
function generateVisualSpecsSection(node, enrichedData, variantData, varNameMap = new Map()) {
|
|
1684
1694
|
const lines = ["", "## Token Specification", ""];
|
|
1685
|
-
//
|
|
1686
|
-
|
|
1695
|
+
// Fill any gaps in the caller-supplied name map from enrichment data.
|
|
1696
|
+
// enrichment entries key off variableId/variableName (NOT id/name), and only
|
|
1697
|
+
// carry a useful name when it actually resolved (not the raw VariableID).
|
|
1687
1698
|
if (enrichedData?.variables_used) {
|
|
1688
1699
|
for (const v of enrichedData.variables_used) {
|
|
1689
|
-
|
|
1700
|
+
if (v.variableId && v.variableName && v.variableName !== v.variableId && !varNameMap.has(v.variableId)) {
|
|
1701
|
+
varNameMap.set(v.variableId, v.variableName);
|
|
1702
|
+
}
|
|
1690
1703
|
}
|
|
1691
1704
|
}
|
|
1692
1705
|
// Per-variant color token table
|
|
@@ -1740,7 +1753,7 @@ function generateVisualSpecsSection(node, enrichedData, variantData) {
|
|
|
1740
1753
|
}
|
|
1741
1754
|
// Spacing tokens with variable names
|
|
1742
1755
|
const visualNode = resolveVisualNode(node);
|
|
1743
|
-
const spacingTokens = collectSpacingTokens(visualNode);
|
|
1756
|
+
const spacingTokens = collectSpacingTokens(visualNode, varNameMap);
|
|
1744
1757
|
if (spacingTokens.length > 0) {
|
|
1745
1758
|
lines.push("### Spacing Tokens");
|
|
1746
1759
|
lines.push("");
|
|
@@ -2565,11 +2578,41 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
2565
2578
|
logger.warn("Enrichment failed, proceeding without token data");
|
|
2566
2579
|
}
|
|
2567
2580
|
}
|
|
2568
|
-
// Build variable name lookup for per-variant color
|
|
2581
|
+
// Build variable name lookup (id → token name) for per-variant color
|
|
2582
|
+
// AND spacing collection. Two sources, in order of authority:
|
|
2583
|
+
// 1. Desktop Bridge local variables (Plugin API getLocalVariablesAsync) —
|
|
2584
|
+
// works on EVERY Figma plan and is the only reliable id→name source.
|
|
2585
|
+
// The REST /files/:key/variables/local endpoint is Enterprise-only
|
|
2586
|
+
// (returns 403 on all other plans), so enrichment's variable map is
|
|
2587
|
+
// almost always empty and bound colors would otherwise render as raw
|
|
2588
|
+
// hex / raw VariableIDs.
|
|
2589
|
+
// 2. Enrichment variables_used — fallback for non-bridge (Cloud/Remote)
|
|
2590
|
+
// paths. NOTE: entries key off variableId/variableName (NOT id/name),
|
|
2591
|
+
// and their name is only useful when it actually resolved to a token
|
|
2592
|
+
// name (not the raw VariableID).
|
|
2569
2593
|
const varNameMap = new Map();
|
|
2570
2594
|
if (enrichedData?.variables_used) {
|
|
2571
2595
|
for (const v of enrichedData.variables_used) {
|
|
2572
|
-
|
|
2596
|
+
if (v.variableId && v.variableName && v.variableName !== v.variableId) {
|
|
2597
|
+
varNameMap.set(v.variableId, v.variableName);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
if (getDesktopConnector) {
|
|
2602
|
+
try {
|
|
2603
|
+
const connector = await getDesktopConnector();
|
|
2604
|
+
const varsResult = await connector.getVariables();
|
|
2605
|
+
const varList = varsResult?.variables || varsResult?.result?.variables;
|
|
2606
|
+
if (Array.isArray(varList)) {
|
|
2607
|
+
for (const v of varList) {
|
|
2608
|
+
if (v?.id && v?.name)
|
|
2609
|
+
varNameMap.set(v.id, v.name);
|
|
2610
|
+
}
|
|
2611
|
+
logger.info({ count: varList.length }, "Resolved variable names via Desktop Bridge for docs");
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
catch {
|
|
2615
|
+
logger.warn("Could not load bridge variables for doc token names — colors may fall back to hex");
|
|
2573
2616
|
}
|
|
2574
2617
|
}
|
|
2575
2618
|
// Collect per-variant color/icon data
|
|
@@ -2657,7 +2700,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
2657
2700
|
}
|
|
2658
2701
|
}
|
|
2659
2702
|
if (s.visualSpecs) {
|
|
2660
|
-
parts.push(generateVisualSpecsSection(nodeForVisual, enrichedData, variantData));
|
|
2703
|
+
parts.push(generateVisualSpecsSection(nodeForVisual, enrichedData, variantData, varNameMap));
|
|
2661
2704
|
includedSections.push("visualSpecs");
|
|
2662
2705
|
}
|
|
2663
2706
|
if (s.typography) {
|
|
@@ -68,12 +68,13 @@ export function searchComponents(manifest, query, options) {
|
|
|
68
68
|
const queryLower = query.toLowerCase();
|
|
69
69
|
const categoryLower = options?.category?.toLowerCase();
|
|
70
70
|
const allResults = [];
|
|
71
|
-
// Search component sets first (they're typically the main design system components)
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
// Search component sets first (they're typically the main design system components).
|
|
72
|
+
// Manifest maps are keyed by component key — always read names from the entry.
|
|
73
|
+
for (const compSet of Object.values(manifest.componentSets)) {
|
|
74
|
+
const nameLower = compSet.name.toLowerCase();
|
|
74
75
|
const descLower = compSet.description?.toLowerCase() || '';
|
|
75
76
|
const matchesQuery = !query || nameLower.includes(queryLower) || descLower.includes(queryLower);
|
|
76
|
-
const matchesCategory = !categoryLower || inferCategory(name).toLowerCase().includes(categoryLower);
|
|
77
|
+
const matchesCategory = !categoryLower || inferCategory(compSet.name).toLowerCase().includes(categoryLower);
|
|
77
78
|
if (matchesQuery && matchesCategory) {
|
|
78
79
|
allResults.push({
|
|
79
80
|
name: compSet.name,
|
|
@@ -81,17 +82,17 @@ export function searchComponents(manifest, query, options) {
|
|
|
81
82
|
nodeId: compSet.nodeId,
|
|
82
83
|
type: 'componentSet',
|
|
83
84
|
description: compSet.description,
|
|
84
|
-
category: inferCategory(name),
|
|
85
|
+
category: inferCategory(compSet.name),
|
|
85
86
|
variantCount: compSet.variants?.length || 0,
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
// Then search standalone components
|
|
90
|
-
for (const
|
|
91
|
-
const nameLower = name.toLowerCase();
|
|
91
|
+
for (const comp of Object.values(manifest.components)) {
|
|
92
|
+
const nameLower = comp.name.toLowerCase();
|
|
92
93
|
const descLower = comp.description?.toLowerCase() || '';
|
|
93
94
|
const matchesQuery = !query || nameLower.includes(queryLower) || descLower.includes(queryLower);
|
|
94
|
-
const matchesCategory = !categoryLower || inferCategory(name).toLowerCase().includes(categoryLower);
|
|
95
|
+
const matchesCategory = !categoryLower || inferCategory(comp.name).toLowerCase().includes(categoryLower);
|
|
95
96
|
if (matchesQuery && matchesCategory) {
|
|
96
97
|
allResults.push({
|
|
97
98
|
name: comp.name,
|
|
@@ -99,13 +100,17 @@ export function searchComponents(manifest, query, options) {
|
|
|
99
100
|
nodeId: comp.nodeId,
|
|
100
101
|
type: 'component',
|
|
101
102
|
description: comp.description,
|
|
102
|
-
category: inferCategory(name),
|
|
103
|
+
category: inferCategory(comp.name),
|
|
103
104
|
defaultSize: comp.defaultSize,
|
|
104
105
|
});
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
const total = allResults.length;
|
|
108
|
-
|
|
109
|
+
// Truncate long doc blocks in search hits — full text is available via
|
|
110
|
+
// figma_get_component_details.
|
|
111
|
+
const paginatedResults = allResults.slice(offset, offset + limit).map((r) => r.description && r.description.length > 200
|
|
112
|
+
? { ...r, description: `${r.description.slice(0, 200)}…`, descriptionTruncated: true }
|
|
113
|
+
: r);
|
|
109
114
|
const hasMore = offset + limit < total;
|
|
110
115
|
return { results: paginatedResults, total, hasMore };
|
|
111
116
|
}
|
|
@@ -121,14 +126,14 @@ function inferCategory(name) {
|
|
|
121
126
|
*/
|
|
122
127
|
export function getCategories(manifest) {
|
|
123
128
|
const categories = new Map();
|
|
124
|
-
for (const
|
|
125
|
-
const cat = inferCategory(name);
|
|
129
|
+
for (const compSet of Object.values(manifest.componentSets)) {
|
|
130
|
+
const cat = inferCategory(compSet.name);
|
|
126
131
|
const existing = categories.get(cat) || { componentCount: 0, componentSetCount: 0 };
|
|
127
132
|
existing.componentSetCount++;
|
|
128
133
|
categories.set(cat, existing);
|
|
129
134
|
}
|
|
130
|
-
for (const
|
|
131
|
-
const cat = inferCategory(name);
|
|
135
|
+
for (const comp of Object.values(manifest.components)) {
|
|
136
|
+
const cat = inferCategory(comp.name);
|
|
132
137
|
const existing = categories.get(cat) || { componentCount: 0, componentSetCount: 0 };
|
|
133
138
|
existing.componentCount++;
|
|
134
139
|
categories.set(cat, existing);
|
|
@@ -155,35 +155,36 @@ export function extractVisualSpec(node) {
|
|
|
155
155
|
return hasData ? spec : undefined;
|
|
156
156
|
}
|
|
157
157
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
158
|
+
* Delta-encode variant visual specs against the first variant that has one.
|
|
159
|
+
* Variants in a set share most visual properties, so repeating the full spec
|
|
160
|
+
* on every variant inflates the payload ~30x for large sets. The base variant
|
|
161
|
+
* keeps its full visualSpec; every other variant gets a visualSpecDelta with
|
|
162
|
+
* only the top-level properties that differ from the base (null means the
|
|
163
|
+
* property exists on the base but not on this variant). Variants identical to
|
|
164
|
+
* the base carry neither field.
|
|
160
165
|
*/
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
for (const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
childInfo.visualSpec = childVisual;
|
|
179
|
-
if (child.characters)
|
|
180
|
-
childInfo.characters = child.characters;
|
|
181
|
-
childSpecs.push(childInfo);
|
|
166
|
+
function deltaEncodeVariantSpecs(variants) {
|
|
167
|
+
const base = variants.find((v) => v.visualSpec);
|
|
168
|
+
if (!base)
|
|
169
|
+
return;
|
|
170
|
+
const baseSpec = base.visualSpec;
|
|
171
|
+
for (const variant of variants) {
|
|
172
|
+
if (variant === base || !variant.visualSpec)
|
|
173
|
+
continue;
|
|
174
|
+
const spec = variant.visualSpec;
|
|
175
|
+
const delta = {};
|
|
176
|
+
for (const key of new Set([...Object.keys(baseSpec), ...Object.keys(spec)])) {
|
|
177
|
+
if (!(key in spec)) {
|
|
178
|
+
delta[key] = null;
|
|
179
|
+
}
|
|
180
|
+
else if (!(key in baseSpec) || JSON.stringify(spec[key]) !== JSON.stringify(baseSpec[key])) {
|
|
181
|
+
delta[key] = spec[key];
|
|
182
|
+
}
|
|
182
183
|
}
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
delete variant.visualSpec;
|
|
185
|
+
if (Object.keys(delta).length > 0)
|
|
186
|
+
variant.visualSpecDelta = delta;
|
|
185
187
|
}
|
|
186
|
-
return result;
|
|
187
188
|
}
|
|
188
189
|
/**
|
|
189
190
|
* Resolve style node IDs to their actual visual values.
|
|
@@ -428,6 +429,7 @@ export function registerDesignSystemTools(server, getFigmaAPI, getCurrentUrl, va
|
|
|
428
429
|
"token values per mode (light/dark), and resolved style values. " +
|
|
429
430
|
"Use this instead of calling individual tools to avoid context window overflow. " +
|
|
430
431
|
"Ideal for AI code generation — use visualSpec for pixel-accurate reproduction. " +
|
|
432
|
+
"Variant specs are delta-encoded: the base variant carries the full visualSpec, siblings carry visualSpecDelta with only the properties that differ. " +
|
|
431
433
|
"Tokens/variables are read through the connected Desktop Bridge or cloud relay and work on ANY Figma plan — no Enterprise required. " +
|
|
432
434
|
"If a tokens fetch ever reports the Variables REST API is plan-limited (403), the bridge/relay is the plan-independent path: ensure it's connected and retry rather than abandoning variables.", {
|
|
433
435
|
fileKey: z
|
|
@@ -600,6 +602,9 @@ export function registerDesignSystemTools(server, getFigmaAPI, getCurrentUrl, va
|
|
|
600
602
|
return entry;
|
|
601
603
|
});
|
|
602
604
|
if (variants.length > 0) {
|
|
605
|
+
// Variants share most visual properties — keep the full spec on
|
|
606
|
+
// the base variant only and encode the rest as deltas
|
|
607
|
+
deltaEncodeVariantSpecs(variants);
|
|
603
608
|
spec.variants = variants;
|
|
604
609
|
}
|
|
605
610
|
if (setNode?.componentPropertyDefinitions) {
|
|
@@ -611,11 +616,11 @@ export function registerDesignSystemTools(server, getFigmaAPI, getCurrentUrl, va
|
|
|
611
616
|
height: setNode.absoluteBoundingBox.height,
|
|
612
617
|
};
|
|
613
618
|
}
|
|
614
|
-
// Extract visual spec from the set node
|
|
619
|
+
// Extract visual spec from the set node itself
|
|
615
620
|
if (setNode) {
|
|
616
|
-
const
|
|
617
|
-
if (
|
|
618
|
-
spec.visualSpec =
|
|
621
|
+
const setSpec = extractVisualSpec(setNode);
|
|
622
|
+
if (setSpec) {
|
|
623
|
+
spec.visualSpec = setSpec;
|
|
619
624
|
}
|
|
620
625
|
}
|
|
621
626
|
componentSpecs.push(spec);
|
|
@@ -638,11 +643,11 @@ export function registerDesignSystemTools(server, getFigmaAPI, getCurrentUrl, va
|
|
|
638
643
|
height: node.absoluteBoundingBox.height,
|
|
639
644
|
};
|
|
640
645
|
}
|
|
641
|
-
// Extract visual spec from the component node
|
|
646
|
+
// Extract visual spec from the component node
|
|
642
647
|
if (node) {
|
|
643
|
-
const
|
|
644
|
-
if (
|
|
645
|
-
spec.visualSpec =
|
|
648
|
+
const nodeSpec = extractVisualSpec(node);
|
|
649
|
+
if (nodeSpec) {
|
|
650
|
+
spec.visualSpec = nodeSpec;
|
|
646
651
|
}
|
|
647
652
|
}
|
|
648
653
|
componentSpecs.push(spec);
|
|
@@ -762,6 +767,10 @@ export function registerDesignSystemTools(server, getFigmaAPI, getCurrentUrl, va
|
|
|
762
767
|
" - layout.itemSpacing → gap\n" +
|
|
763
768
|
" - layout.primaryAxisAlign → justify-content, counterAxisAlign → align-items\n" +
|
|
764
769
|
" - typography → font-family, font-size, font-weight, line-height, letter-spacing\n" +
|
|
770
|
+
" - Variant specs are delta-encoded: one base variant carries the full visualSpec; " +
|
|
771
|
+
"sibling variants carry 'visualSpecDelta' with ONLY the properties that differ from the base " +
|
|
772
|
+
"(null = property absent on this variant). Merge base visualSpec + visualSpecDelta to get a " +
|
|
773
|
+
"variant's full spec. A variant with neither field is visually identical to the base.\n" +
|
|
765
774
|
"3. Do NOT add decorative elements (colored borders, accents, dividers, gradients) " +
|
|
766
775
|
"unless they appear in the visualSpec data.\n" +
|
|
767
776
|
"4. Use 'imageUrl' screenshots as the visual ground truth. If the screenshot " +
|
|
@@ -34,6 +34,9 @@ function buildReport(opts) {
|
|
|
34
34
|
if (plugin.editorType && plugin.editorType !== "figma") {
|
|
35
35
|
lines.push(`- Editor type: ${plugin.editorType}.`);
|
|
36
36
|
}
|
|
37
|
+
if (plugin.pluginUpdateAvailable) {
|
|
38
|
+
lines.push(`- ⚠️ **Plugin update available**: the imported plugin${plugin.pluginVersion ? ` (v${plugin.pluginVersion})` : " (version unknown — very old)"} is older than this server (v${opts.getServerVersion()}). Figma caches plugin files, so re-import it: Figma Desktop → Plugins → Development → Import plugin from manifest → select manifest.json. Until then, recently added or fixed plugin features may silently misbehave.`);
|
|
39
|
+
}
|
|
37
40
|
}
|
|
38
41
|
else {
|
|
39
42
|
lines.push(`- ⚠️ Desktop Bridge plugin not connected${plugin.port ? ` (server is listening on port ${plugin.port})` : ""}.`);
|
|
@@ -83,17 +83,23 @@ export class EnrichmentService {
|
|
|
83
83
|
const enriched = {
|
|
84
84
|
...variable,
|
|
85
85
|
};
|
|
86
|
-
// Resolve values for all modes
|
|
86
|
+
// Resolve values for all modes — each mode must resolve ITS OWN
|
|
87
|
+
// value, so the modeId is threaded through the resolver (which
|
|
88
|
+
// also keys its cache per mode).
|
|
87
89
|
if (options.include_exports !== false) {
|
|
88
90
|
const resolved_values = {};
|
|
89
|
-
for (const
|
|
90
|
-
const resolvedValue = await this.styleResolver.resolveVariableValue(variable, variablesMap, options.max_depth);
|
|
91
|
+
for (const modeId of Object.keys(variable.valuesByMode || {})) {
|
|
92
|
+
const resolvedValue = await this.styleResolver.resolveVariableValue(variable, variablesMap, options.max_depth, 0, modeId);
|
|
91
93
|
resolved_values[modeId] = resolvedValue;
|
|
92
94
|
}
|
|
93
95
|
enriched.resolved_values = resolved_values;
|
|
94
|
-
// Generate export formats using first mode
|
|
96
|
+
// Generate export formats using first mode. Guard only against
|
|
97
|
+
// null/undefined — legitimate falsy values (0, false, "") must
|
|
98
|
+
// still produce export formats.
|
|
95
99
|
const firstModeValue = Object.values(resolved_values)[0];
|
|
96
|
-
if (firstModeValue
|
|
100
|
+
if (firstModeValue !== null &&
|
|
101
|
+
firstModeValue !== undefined &&
|
|
102
|
+
options.export_formats) {
|
|
97
103
|
enriched.export_formats = this.styleResolver.generateExportFormats(variable.name, firstModeValue, variable.resolvedType, options.export_formats);
|
|
98
104
|
}
|
|
99
105
|
}
|
|
@@ -56,29 +56,38 @@ export class StyleValueResolver {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
|
-
* Resolve a variable's value, handling alias chains
|
|
59
|
+
* Resolve a variable's value, handling alias chains.
|
|
60
|
+
*
|
|
61
|
+
* `modeId` selects which mode's value to resolve; omitted, it falls back
|
|
62
|
+
* to the variable's first mode (legacy behavior for callers that don't
|
|
63
|
+
* care about modes). The cache is keyed per (variable, mode) so
|
|
64
|
+
* multi-mode variables don't all resolve to the first mode's value.
|
|
60
65
|
*/
|
|
61
|
-
async resolveVariableValue(variable, allVariables, maxDepth = 10, currentDepth = 0) {
|
|
66
|
+
async resolveVariableValue(variable, allVariables, maxDepth = 10, currentDepth = 0, modeId) {
|
|
62
67
|
if (currentDepth >= maxDepth) {
|
|
63
68
|
this.logger.warn({
|
|
64
69
|
variable: variable.name,
|
|
65
70
|
}, "Max resolution depth reached");
|
|
66
71
|
return null;
|
|
67
72
|
}
|
|
68
|
-
|
|
73
|
+
// Pick the mode to resolve: the requested modeId when this variable
|
|
74
|
+
// has a value for it, otherwise the first available mode (alias
|
|
75
|
+
// targets in other collections have different modeIds).
|
|
76
|
+
const modes = Object.keys(variable.valuesByMode || {});
|
|
77
|
+
if (modes.length === 0) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const effectiveMode = modeId !== undefined && variable.valuesByMode[modeId] !== undefined
|
|
81
|
+
? modeId
|
|
82
|
+
: modes[0];
|
|
83
|
+
const cacheKey = `var:${variable.id}:${effectiveMode}`;
|
|
69
84
|
if (this.variableCache.has(cacheKey)) {
|
|
70
85
|
return this.variableCache.get(cacheKey);
|
|
71
86
|
}
|
|
72
87
|
try {
|
|
73
|
-
|
|
74
|
-
const modes = Object.keys(variable.valuesByMode || {});
|
|
75
|
-
if (modes.length === 0) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
const defaultMode = modes[0]; // TODO: Support mode selection
|
|
79
|
-
const value = variable.valuesByMode[defaultMode];
|
|
88
|
+
const value = variable.valuesByMode[effectiveMode];
|
|
80
89
|
// Check if this is an alias (reference to another variable)
|
|
81
|
-
if (typeof value === "object" && value.type === "VARIABLE_ALIAS") {
|
|
90
|
+
if (value !== null && typeof value === "object" && value.type === "VARIABLE_ALIAS") {
|
|
82
91
|
const targetVariable = allVariables.get(value.id);
|
|
83
92
|
if (!targetVariable) {
|
|
84
93
|
this.logger.warn({
|
|
@@ -87,8 +96,9 @@ export class StyleValueResolver {
|
|
|
87
96
|
}, "Variable alias target not found");
|
|
88
97
|
return null;
|
|
89
98
|
}
|
|
90
|
-
// Recursively resolve the alias
|
|
91
|
-
|
|
99
|
+
// Recursively resolve the alias, carrying the requested modeId
|
|
100
|
+
// through so same-collection targets resolve the same mode.
|
|
101
|
+
const resolvedValue = await this.resolveVariableValue(targetVariable, allVariables, maxDepth, currentDepth + 1, modeId);
|
|
92
102
|
this.variableCache.set(cacheKey, resolvedValue);
|
|
93
103
|
return resolvedValue;
|
|
94
104
|
}
|
|
@@ -109,7 +119,9 @@ export class StyleValueResolver {
|
|
|
109
119
|
* Format a variable value based on its type
|
|
110
120
|
*/
|
|
111
121
|
formatVariableValue(value, type) {
|
|
112
|
-
|
|
122
|
+
// Guard only null/undefined — 0, false, and "" are legitimate
|
|
123
|
+
// variable values (opacity 0, boolean flags off, empty strings).
|
|
124
|
+
if (value === null || value === undefined)
|
|
113
125
|
return null;
|
|
114
126
|
switch (type) {
|
|
115
127
|
case "COLOR":
|
|
@@ -133,10 +145,18 @@ export class StyleValueResolver {
|
|
|
133
145
|
return color;
|
|
134
146
|
}
|
|
135
147
|
if (color.r !== undefined && color.g !== undefined && color.b !== undefined) {
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
148
|
+
const clampByte = (f) => Math.max(0, Math.min(255, Math.round(f * 255)));
|
|
149
|
+
const toHex = (byte) => byte.toString(16).padStart(2, "0");
|
|
150
|
+
const r = clampByte(color.r);
|
|
151
|
+
const g = clampByte(color.g);
|
|
152
|
+
const b = clampByte(color.b);
|
|
153
|
+
let hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
154
|
+
// Preserve alpha: append the alpha byte for semi-transparent colors
|
|
155
|
+
// (mirrors rgbaToHex in tokens/figma-converter.ts).
|
|
156
|
+
if (color.a !== undefined && color.a < 1) {
|
|
157
|
+
hex += toHex(clampByte(color.a));
|
|
158
|
+
}
|
|
159
|
+
return hex.toUpperCase();
|
|
140
160
|
}
|
|
141
161
|
return null;
|
|
142
162
|
}
|