@dannote/figma-use 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-01-18
11
+
12
+ ### Added
13
+
14
+ - **`defineVars` API for Figma variables** — bind colors to variables by name
15
+ ```tsx
16
+ const colors = defineVars({
17
+ primary: { name: 'Colors/Gray/50', value: '#F8FAFC' },
18
+ accent: { name: 'Colors/Blue/500', value: '#3B82F6' },
19
+ })
20
+ <Frame style={{ backgroundColor: colors.primary }} />
21
+ ```
22
+ - Variable binding for `backgroundColor`, `borderColor`, and text `color`
23
+ - Variables resolved by name at render time (no more magic IDs)
24
+ - `defineVars` support in stdin snippets
25
+ - Explicit fallback values in `defineVars` for proper color display
26
+
27
+ ### Fixed
28
+
29
+ - Auto-layout now works correctly via `trigger-layout` post-render
30
+ - Nested auto-layout frames trigger recursively
31
+ - Variable binding encoding matches Figma's exact wire format
32
+
33
+ ### Changed
34
+
35
+ - Marked React render and variable bindings as **experimental** in docs
36
+
37
+ ## [0.3.1] - 2026-01-18
38
+
39
+ ### Added
40
+
41
+ - **Variable binding via multiplayer protocol** — bind fill colors to Figma variables without plugin API
42
+ - `encodePaintWithVariableBinding()` — encode Paint with color variable binding
43
+ - `encodeNodeChangeWithVariables()` — encode NodeChange with variable-bound paints
44
+ - `parseVariableId()` — parse "VariableID:sessionID:localID" strings
45
+ - New exports: `VariableBinding`, `encodePaintWithVariableBinding`, `encodeNodeChangeWithVariables`, `parseVariableId`
46
+ - `bind-fill-variable` plugin command — bind fill color to variable
47
+ - `bind-stroke-variable` plugin command — bind stroke color to variable
48
+
49
+ ### Fixed
50
+
51
+ - Message field mapping: nodeChanges is field 4, reconnectSequenceNumber is field 25
52
+ - Paint variable binding format now matches Figma's exact wire format
53
+
54
+ ### Technical
55
+
56
+ - Discovered Figma's variable binding wire format via WebSocket traffic analysis
57
+ - Created capture/diff tools for binary protocol analysis (`scripts/capture.ts`, `scripts/diff-hex.ts`)
58
+ - 142 tests passing
59
+
10
60
  ## [0.3.0] - 2025-01-17
11
61
 
12
62
  ### Added
package/README.md CHANGED
@@ -258,7 +258,9 @@ figma-use group ungroup <id>
258
258
  figma-use group flatten "1:2,1:3"
259
259
  ```
260
260
 
261
- ### Render React Components
261
+ ### Render React Components (Experimental)
262
+
263
+ > ⚠️ **Experimental**: The React render feature uses Figma's internal multiplayer protocol, which is undocumented and may change without notice. Use for prototyping and automation, not production workflows.
262
264
 
263
265
  Render TSX/JSX components directly to Figma via WebSocket (bypasses plugin API for ~100x speed):
264
266
 
@@ -443,3 +445,33 @@ Workflow:
443
445
  ## License
444
446
 
445
447
  MIT
448
+
449
+ ### Variable Bindings (Experimental)
450
+
451
+ > ⚠️ **Experimental**: Variable binding uses reverse-engineered protocol. Supports `backgroundColor`, `borderColor`, and text `color`.
452
+
453
+ Bind Figma variables to colors using human-readable names:
454
+
455
+ ```tsx
456
+ // tokens.figma.ts
457
+ import { defineVars } from '@dannote/figma-use'
458
+
459
+ export const colors = defineVars({
460
+ primary: { name: 'Colors/Gray/50', value: '#F8FAFC' },
461
+ accent: { name: 'Colors/Blue/500', value: '#3B82F6' },
462
+ text: { name: 'Colors/Gray/900', value: '#0F172A' },
463
+ })
464
+
465
+ // Card.figma.tsx
466
+ import { colors } from './tokens.figma'
467
+
468
+ export function Card({ title }: { title: string }) {
469
+ return (
470
+ <Frame style={{ backgroundColor: colors.primary }}>
471
+ <Text style={{ color: colors.text }}>{title}</Text>
472
+ </Frame>
473
+ )
474
+ }
475
+ ```
476
+
477
+ The `value` field provides a fallback color for display. Variables are bound at the protocol level — no plugin API calls needed.
package/SKILL.md CHANGED
@@ -148,7 +148,9 @@ figma-use group create "1:2,1:3"
148
148
  figma-use group ungroup <id>
149
149
  ```
150
150
 
151
- ### Render React Components
151
+ ### Render React Components (Experimental)
152
+
153
+ > ⚠️ Uses Figma's internal multiplayer protocol — may break without notice.
152
154
 
153
155
  Render TSX/JSX directly to Figma (~100x faster than plugin API):
154
156
 
@@ -178,6 +180,25 @@ figma-use render ./Card.figma.tsx --parent "1:23"
178
180
 
179
181
  Available elements: `Frame`, `Rectangle`, `Ellipse`, `Text`, `Line`, `Star`, `Polygon`, `Vector`, `Component`, `Instance`, `Group`
180
182
 
183
+ #### Variable Bindings (Experimental)
184
+
185
+ Bind Figma variables to colors by name with fallback values:
186
+
187
+ ```tsx
188
+ import { defineVars, Frame } from '@dannote/figma-use'
189
+
190
+ const colors = defineVars({
191
+ primary: { name: 'Colors/Gray/50', value: '#F8FAFC' },
192
+ border: { name: 'Colors/Gray/500', value: '#64748B' },
193
+ })
194
+
195
+ export default () => (
196
+ <Frame style={{ backgroundColor: colors.primary, borderColor: colors.border }} />
197
+ )
198
+ ```
199
+
200
+ Supports: `backgroundColor`, `borderColor`, text `color`.
201
+
181
202
  ### Eval (Arbitrary Code)
182
203
 
183
204
  ```bash
package/dist/cli/index.js CHANGED
@@ -18920,6 +18920,92 @@ var require_react_reconciler = __commonJS((exports, module) => {
18920
18920
  }
18921
18921
  });
18922
18922
 
18923
+ // packages/cli/src/render/vars.ts
18924
+ var exports_vars = {};
18925
+ __export(exports_vars, {
18926
+ resolveVariable: () => resolveVariable,
18927
+ loadVariablesIntoRegistry: () => loadVariablesIntoRegistry,
18928
+ isVariable: () => isVariable,
18929
+ isRegistryLoaded: () => isRegistryLoaded,
18930
+ getRegistrySize: () => getRegistrySize,
18931
+ figmaVar: () => figmaVar,
18932
+ defineVars: () => defineVars
18933
+ });
18934
+ function isVariable(value) {
18935
+ return typeof value === "object" && value !== null && VAR_SYMBOL in value;
18936
+ }
18937
+ function loadVariablesIntoRegistry(variables) {
18938
+ variableRegistry.clear();
18939
+ for (const v3 of variables) {
18940
+ const match = v3.id.match(/VariableID:(\d+):(\d+)/);
18941
+ if (match) {
18942
+ variableRegistry.set(v3.name, {
18943
+ id: v3.id,
18944
+ sessionID: parseInt(match[1], 10),
18945
+ localID: parseInt(match[2], 10)
18946
+ });
18947
+ }
18948
+ }
18949
+ }
18950
+ function resolveVariable(variable) {
18951
+ if (variable._resolved) {
18952
+ return variable._resolved;
18953
+ }
18954
+ const idMatch = variable.name.match(/^(?:VariableID:)?(\d+):(\d+)$/);
18955
+ if (idMatch) {
18956
+ const resolved2 = {
18957
+ id: `VariableID:${idMatch[1]}:${idMatch[2]}`,
18958
+ sessionID: parseInt(idMatch[1], 10),
18959
+ localID: parseInt(idMatch[2], 10)
18960
+ };
18961
+ variable._resolved = resolved2;
18962
+ return resolved2;
18963
+ }
18964
+ const resolved = variableRegistry.get(variable.name);
18965
+ if (!resolved) {
18966
+ const available = Array.from(variableRegistry.keys()).slice(0, 5).join(", ");
18967
+ throw new Error(`Variable "${variable.name}" not found. ` + `Available: ${available}${variableRegistry.size > 5 ? "..." : ""}. ` + `Make sure variables are loaded before render.`);
18968
+ }
18969
+ variable._resolved = resolved;
18970
+ return resolved;
18971
+ }
18972
+ function isRegistryLoaded() {
18973
+ return variableRegistry.size > 0;
18974
+ }
18975
+ function getRegistrySize() {
18976
+ return variableRegistry.size;
18977
+ }
18978
+ function defineVars(vars) {
18979
+ const result = {};
18980
+ for (const [key, def] of Object.entries(vars)) {
18981
+ if (typeof def === "string") {
18982
+ result[key] = {
18983
+ [VAR_SYMBOL]: true,
18984
+ name: def
18985
+ };
18986
+ } else {
18987
+ result[key] = {
18988
+ [VAR_SYMBOL]: true,
18989
+ name: def.name,
18990
+ value: def.value
18991
+ };
18992
+ }
18993
+ }
18994
+ return result;
18995
+ }
18996
+ function figmaVar(name, value) {
18997
+ return {
18998
+ [VAR_SYMBOL]: true,
18999
+ name,
19000
+ value
19001
+ };
19002
+ }
19003
+ var VAR_SYMBOL, variableRegistry;
19004
+ var init_vars = __esm(() => {
19005
+ VAR_SYMBOL = Symbol.for("figma.variable");
19006
+ variableRegistry = new Map;
19007
+ });
19008
+
18923
19009
  // node_modules/esbuild/lib/main.js
18924
19010
  var require_main = __commonJS((exports, module) => {
18925
19011
  var __dirname = "/Users/dannote/Development/figma-use/node_modules/esbuild/lib", __filename = "/Users/dannote/Development/figma-use/node_modules/esbuild/lib/main.js";
@@ -27547,6 +27633,7 @@ function parseColor(color) {
27547
27633
  }
27548
27634
 
27549
27635
  // packages/cli/src/render/reconciler.ts
27636
+ init_vars();
27550
27637
  function styleToNodeChange(type, props, localID, sessionID, parentGUID, position, textContent) {
27551
27638
  const style = props.style || {};
27552
27639
  const name = props.name || type;
@@ -27575,22 +27662,52 @@ function styleToNodeChange(type, props, localID, sessionID, parentGUID, position
27575
27662
  m12: y5
27576
27663
  };
27577
27664
  if (style.backgroundColor) {
27578
- const color = parseColor(style.backgroundColor);
27579
- nodeChange.fillPaints = [{
27580
- type: "SOLID",
27581
- color: { r: color.r, g: color.g, b: color.b, a: color.a },
27582
- opacity: color.a,
27583
- visible: true
27584
- }];
27665
+ const bgColor = style.backgroundColor;
27666
+ if (isVariable(bgColor)) {
27667
+ const resolved = resolveVariable(bgColor);
27668
+ const fallback = bgColor.value ? parseColor(bgColor.value) : { r: 0, g: 0, b: 0, a: 1 };
27669
+ nodeChange.fillPaints = [{
27670
+ type: "SOLID",
27671
+ color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
27672
+ opacity: 1,
27673
+ visible: true,
27674
+ colorVariableBinding: {
27675
+ variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
27676
+ }
27677
+ }];
27678
+ } else {
27679
+ const color = parseColor(bgColor);
27680
+ nodeChange.fillPaints = [{
27681
+ type: "SOLID",
27682
+ color: { r: color.r, g: color.g, b: color.b, a: color.a },
27683
+ opacity: color.a,
27684
+ visible: true
27685
+ }];
27686
+ }
27585
27687
  }
27586
27688
  if (style.borderColor) {
27587
- const color = parseColor(style.borderColor);
27588
- nodeChange.strokePaints = [{
27589
- type: "SOLID",
27590
- color: { r: color.r, g: color.g, b: color.b, a: color.a },
27591
- opacity: color.a,
27592
- visible: true
27593
- }];
27689
+ const borderColor = style.borderColor;
27690
+ if (isVariable(borderColor)) {
27691
+ const resolved = resolveVariable(borderColor);
27692
+ const fallback = borderColor.value ? parseColor(borderColor.value) : { r: 0, g: 0, b: 0, a: 1 };
27693
+ nodeChange.strokePaints = [{
27694
+ type: "SOLID",
27695
+ color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
27696
+ opacity: 1,
27697
+ visible: true,
27698
+ colorVariableBinding: {
27699
+ variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
27700
+ }
27701
+ }];
27702
+ } else {
27703
+ const color = parseColor(borderColor);
27704
+ nodeChange.strokePaints = [{
27705
+ type: "SOLID",
27706
+ color: { r: color.r, g: color.g, b: color.b, a: color.a },
27707
+ opacity: color.a,
27708
+ visible: true
27709
+ }];
27710
+ }
27594
27711
  nodeChange.strokeWeight = Number(style.borderWidth ?? 1);
27595
27712
  }
27596
27713
  if (style.borderRadius !== undefined) {
@@ -27614,6 +27731,8 @@ function styleToNodeChange(type, props, localID, sessionID, parentGUID, position
27614
27731
  }
27615
27732
  if (style.flexDirection) {
27616
27733
  nodeChange.stackMode = style.flexDirection === "row" ? "HORIZONTAL" : "VERTICAL";
27734
+ nodeChange.stackPrimarySizing = "RESIZE_TO_FIT";
27735
+ nodeChange.stackCounterSizing = "RESIZE_TO_FIT";
27617
27736
  }
27618
27737
  if (style.gap !== undefined) {
27619
27738
  nodeChange.stackSpacing = Number(style.gap);
@@ -27667,13 +27786,28 @@ function styleToNodeChange(type, props, localID, sessionID, parentGUID, position
27667
27786
  nc.textAlignHorizontal = map[style.textAlign] || "LEFT";
27668
27787
  }
27669
27788
  if (style.color) {
27670
- const color = parseColor(style.color);
27671
- nodeChange.fillPaints = [{
27672
- type: "SOLID",
27673
- color: { r: color.r, g: color.g, b: color.b, a: color.a },
27674
- opacity: color.a,
27675
- visible: true
27676
- }];
27789
+ const textColor = style.color;
27790
+ if (isVariable(textColor)) {
27791
+ const resolved = resolveVariable(textColor);
27792
+ const fallback = textColor.value ? parseColor(textColor.value) : { r: 0, g: 0, b: 0, a: 1 };
27793
+ nodeChange.fillPaints = [{
27794
+ type: "SOLID",
27795
+ color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
27796
+ opacity: 1,
27797
+ visible: true,
27798
+ colorVariableBinding: {
27799
+ variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
27800
+ }
27801
+ }];
27802
+ } else {
27803
+ const color = parseColor(textColor);
27804
+ nodeChange.fillPaints = [{
27805
+ type: "SOLID",
27806
+ color: { r: color.r, g: color.g, b: color.b, a: color.a },
27807
+ opacity: color.a,
27808
+ visible: true
27809
+ }];
27810
+ }
27677
27811
  }
27678
27812
  }
27679
27813
  return nodeChange;
@@ -27866,6 +28000,7 @@ function renderToNodeChanges(element, options) {
27866
28000
  };
27867
28001
  }
27868
28002
  // packages/cli/src/render/components.tsx
28003
+ init_vars();
27869
28004
  var React = __toESM(require_react(), 1);
27870
28005
  var c6 = (type) => (props) => React.createElement(type, props);
27871
28006
  var Frame = c6("frame");
@@ -27921,8 +28056,27 @@ function findNodeModulesDir() {
27921
28056
  var JSX_DEFINE = Object.fromEntries(INTRINSIC_ELEMENTS.map((name) => [name, JSON.stringify(name.toLowerCase())]));
27922
28057
  function transformJsxSnippet(code) {
27923
28058
  const snippet = code.trim();
27924
- const isModule = snippet.includes("import ") || snippet.includes("export ");
27925
- const fullCode = isModule ? snippet : `export default (React) => () => (${snippet});`;
28059
+ if (snippet.includes("import ") || snippet.includes("export ")) {
28060
+ return import_esbuild.transformSync(snippet, {
28061
+ loader: "tsx",
28062
+ jsx: "transform",
28063
+ jsxFactory: "React.createElement",
28064
+ jsxFragment: "React.Fragment",
28065
+ define: JSX_DEFINE
28066
+ }).code;
28067
+ }
28068
+ const jsxStart = snippet.search(/<[A-Z]/);
28069
+ const hasSetupCode = jsxStart > 0;
28070
+ const usesDefineVars = snippet.includes("defineVars");
28071
+ let fullCode;
28072
+ if (hasSetupCode) {
28073
+ const setupPart = snippet.slice(0, jsxStart).trim();
28074
+ const jsxPart = snippet.slice(jsxStart);
28075
+ const params = usesDefineVars ? "(React, { defineVars })" : "(React)";
28076
+ fullCode = `export default ${params} => { ${setupPart}; return () => (${jsxPart}); };`;
28077
+ } else {
28078
+ fullCode = `export default (React) => () => (${snippet});`;
28079
+ }
27926
28080
  const result = import_esbuild.transformSync(fullCode, {
27927
28081
  loader: "tsx",
27928
28082
  jsx: "transform",
@@ -27973,8 +28127,9 @@ var render_default = defineCommand({
27973
28127
  const module = await import(filePath);
27974
28128
  const exportName = args.export || "default";
27975
28129
  let Component2 = module[exportName];
27976
- if (typeof Component2 === "function" && Component2.length === 1 && args.stdin) {
27977
- Component2 = Component2(React2);
28130
+ if (typeof Component2 === "function" && (Component2.length === 1 || Component2.length === 2) && args.stdin) {
28131
+ const { defineVars: defineVars2 } = await Promise.resolve().then(() => (init_vars(), exports_vars));
28132
+ Component2 = Component2(React2, { defineVars: defineVars2 });
27978
28133
  }
27979
28134
  if (!Component2) {
27980
28135
  console.error(fail(`Export "${exportName}" not found`));
@@ -28003,6 +28158,12 @@ var render_default = defineCommand({
28003
28158
  throw new Error(data.error);
28004
28159
  }
28005
28160
  };
28161
+ if (!isRegistryLoaded()) {
28162
+ try {
28163
+ const variables = await sendCommand("get-variables", { simple: true });
28164
+ loadVariablesIntoRegistry(variables);
28165
+ } catch {}
28166
+ }
28006
28167
  const props = args.props ? JSON.parse(args.props) : {};
28007
28168
  const element = React2.createElement(Component2, props);
28008
28169
  const result = renderToNodeChanges(element, {
@@ -10178,6 +10178,21 @@ message PaintFilterMessage {
10178
10178
  float brightness = 10 [deprecated];
10179
10179
  }
10180
10180
 
10181
+ // Variable binding embedded in Paint - discovered via WS sniffing 2026-01
10182
+ // Figma wire format: 15 01 04 01 {sessionID varint} {localID varint}
10183
+ // - 15 = field 21 in Paint
10184
+ // - 01 = always 1 (binding type for color)
10185
+ // - 04 = field 4
10186
+ // - 01 = always 1 (flag)
10187
+ // - then GUID as raw sessionID + localID varints (no field numbers)
10188
+ // NOTE: kiwi-schema cannot produce this exact format, handled manually in codec
10189
+ message PaintVariableBinding {
10190
+ uint bindingType = 1; // Always 1 for color
10191
+ uint flag = 2; // Placeholder
10192
+ uint flag2 = 3; // Placeholder
10193
+ GUID variableID = 4; // Variable GUID
10194
+ }
10195
+
10181
10196
  message Paint {
10182
10197
  PaintType type = 1;
10183
10198
  Color color = 2;
@@ -10199,6 +10214,7 @@ message Paint {
10199
10214
  Video video = 18;
10200
10215
  uint originalImageWidth = 19;
10201
10216
  uint originalImageHeight = 20;
10217
+ PaintVariableBinding variableBinding = 21; // Not in fig-kiwi. Discovered via WS sniffing 2026-01
10202
10218
  }
10203
10219
 
10204
10220
  message FontMetaData {
@@ -11320,6 +11336,8 @@ message Message {
11320
11336
  uint reconnectSequenceNumber = 25;
11321
11337
  string pasteBranchSourceFileKey = 26;
11322
11338
  EditorType pasteEditorType = 27;
11339
+ // Field 38 (timestamp) exists but kiwi-schema limits field IDs to 28
11340
+ // Discovered via WS sniffing 2026-01, skipped during manual pre-processing
11323
11341
  }
11324
11342
 
11325
11343
  message DiffChunk {
@@ -11540,8 +11558,11 @@ var init_schema = __esm(() => {
11540
11558
  var exports_codec = {};
11541
11559
  __export(exports_codec, {
11542
11560
  peekMessageType: () => peekMessageType,
11561
+ parseVariableId: () => parseVariableId,
11543
11562
  isCodecReady: () => isCodecReady,
11544
11563
  initCodec: () => initCodec,
11564
+ encodePaintWithVariableBinding: () => encodePaintWithVariableBinding,
11565
+ encodeNodeChangeWithVariables: () => encodeNodeChangeWithVariables,
11545
11566
  encodeMessage: () => encodeMessage,
11546
11567
  decompress: () => decompress,
11547
11568
  decodeMessage: () => decodeMessage,
@@ -11569,8 +11590,36 @@ function encodeMessage(message) {
11569
11590
  if (!compiledSchema) {
11570
11591
  throw new Error("Codec not initialized. Call initCodec() first.");
11571
11592
  }
11572
- const encoded = compiledSchema.encodeMessage(message);
11573
- return compress(encoded);
11593
+ const hasVariables = message.nodeChanges?.some((nc) => nc.fillPaints?.some((p4) => p4.colorVariableBinding) || nc.strokePaints?.some((p4) => p4.colorVariableBinding));
11594
+ if (!hasVariables) {
11595
+ const encoded = compiledSchema.encodeMessage(message);
11596
+ return compress(encoded);
11597
+ }
11598
+ const messageWithoutNodes = { ...message, nodeChanges: [] };
11599
+ const baseEncoded = compiledSchema.encodeMessage(messageWithoutNodes);
11600
+ const baseHex = Buffer.from(baseEncoded).toString("hex");
11601
+ const nodeChangeBytes = [];
11602
+ for (const nc of message.nodeChanges || []) {
11603
+ const encoded = encodeNodeChangeWithVariables(nc);
11604
+ nodeChangeBytes.push(encoded);
11605
+ }
11606
+ const emptyArrayPattern = "0400";
11607
+ const emptyArrayIdx = baseHex.indexOf(emptyArrayPattern);
11608
+ if (emptyArrayIdx === -1) {
11609
+ const encoded = compiledSchema.encodeMessage(message);
11610
+ return compress(encoded);
11611
+ }
11612
+ const ncBytes = [4];
11613
+ ncBytes.push(...encodeVarint(nodeChangeBytes.length));
11614
+ for (const ncArr of nodeChangeBytes) {
11615
+ ncBytes.push(...Array.from(ncArr));
11616
+ }
11617
+ const beforeArray = baseHex.slice(0, emptyArrayIdx);
11618
+ const afterArray = baseHex.slice(emptyArrayIdx + 4);
11619
+ const ncHex = Buffer.from(ncBytes).toString("hex");
11620
+ const finalHex = beforeArray + ncHex + afterArray;
11621
+ const finalBytes = new Uint8Array(finalHex.match(/.{2}/g).map((b2) => parseInt(b2, 16)));
11622
+ return compress(finalBytes);
11574
11623
  }
11575
11624
  function decodeMessage(data) {
11576
11625
  if (!compiledSchema) {
@@ -11644,6 +11693,114 @@ function createNodeChange(opts) {
11644
11693
  }
11645
11694
  return change;
11646
11695
  }
11696
+ function encodeVarint(value) {
11697
+ const bytes = [];
11698
+ while (value > 127) {
11699
+ bytes.push(value & 127 | 128);
11700
+ value >>>= 7;
11701
+ }
11702
+ bytes.push(value);
11703
+ return bytes;
11704
+ }
11705
+ function encodePaintWithVariableBinding(paint, variableSessionID, variableLocalID) {
11706
+ if (!compiledSchema) {
11707
+ throw new Error("Codec not initialized. Call initCodec() first.");
11708
+ }
11709
+ const basePaint = { ...paint };
11710
+ delete basePaint.colorVariableBinding;
11711
+ const baseBytes = compiledSchema.encodePaint(basePaint);
11712
+ const baseArray = Array.from(baseBytes);
11713
+ if (baseArray[baseArray.length - 1] === 0) {
11714
+ baseArray.pop();
11715
+ }
11716
+ baseArray.push(21, 1);
11717
+ baseArray.push(4, 1);
11718
+ baseArray.push(...encodeVarint(variableSessionID));
11719
+ baseArray.push(...encodeVarint(variableLocalID));
11720
+ baseArray.push(0, 0, 2, 3, 3, 4);
11721
+ baseArray.push(0, 0);
11722
+ return new Uint8Array(baseArray);
11723
+ }
11724
+ function parseVariableId(variableId) {
11725
+ const match = variableId.match(/VariableID:(\d+):(\d+)/);
11726
+ if (!match)
11727
+ return null;
11728
+ return {
11729
+ sessionID: parseInt(match[1], 10),
11730
+ localID: parseInt(match[2], 10)
11731
+ };
11732
+ }
11733
+ function encodeNodeChangeWithVariables(nodeChange) {
11734
+ if (!compiledSchema) {
11735
+ throw new Error("Codec not initialized. Call initCodec() first.");
11736
+ }
11737
+ const hasFillBinding = nodeChange.fillPaints?.some((p4) => p4.colorVariableBinding);
11738
+ const hasStrokeBinding = nodeChange.strokePaints?.some((p4) => p4.colorVariableBinding);
11739
+ if (!hasFillBinding && !hasStrokeBinding) {
11740
+ return compiledSchema.encodeNodeChange(nodeChange);
11741
+ }
11742
+ const cleanNodeChange = { ...nodeChange };
11743
+ if (cleanNodeChange.fillPaints) {
11744
+ cleanNodeChange.fillPaints = cleanNodeChange.fillPaints.map((p4) => {
11745
+ const clean2 = { ...p4 };
11746
+ delete clean2.colorVariableBinding;
11747
+ return clean2;
11748
+ });
11749
+ }
11750
+ if (cleanNodeChange.strokePaints) {
11751
+ cleanNodeChange.strokePaints = cleanNodeChange.strokePaints.map((p4) => {
11752
+ const clean2 = { ...p4 };
11753
+ delete clean2.colorVariableBinding;
11754
+ return clean2;
11755
+ });
11756
+ }
11757
+ const baseBytes = compiledSchema.encodeNodeChange(cleanNodeChange);
11758
+ let hex2 = Buffer.from(baseBytes).toString("hex");
11759
+ if (hasFillBinding && nodeChange.fillPaints?.[0]?.colorVariableBinding) {
11760
+ hex2 = injectVariableBinding(hex2, "2601", nodeChange.fillPaints[0].colorVariableBinding);
11761
+ }
11762
+ if (hasStrokeBinding && nodeChange.strokePaints?.[0]?.colorVariableBinding) {
11763
+ hex2 = injectVariableBinding(hex2, "2701", nodeChange.strokePaints[0].colorVariableBinding);
11764
+ }
11765
+ return new Uint8Array(hex2.match(/.{2}/g).map((b2) => parseInt(b2, 16)));
11766
+ }
11767
+ function injectVariableBinding(hex2, marker, binding) {
11768
+ const markerIdx = hex2.indexOf(marker);
11769
+ if (markerIdx === -1)
11770
+ return hex2;
11771
+ const visiblePattern = "0401";
11772
+ let patternIdx = hex2.indexOf(visiblePattern, markerIdx);
11773
+ if (patternIdx === -1)
11774
+ return hex2;
11775
+ let insertPoint = patternIdx + visiblePattern.length;
11776
+ if (hex2.slice(insertPoint, insertPoint + 4) === "0501") {
11777
+ insertPoint += 4;
11778
+ }
11779
+ const varBytes = [
11780
+ 21,
11781
+ 1,
11782
+ 4,
11783
+ 1,
11784
+ ...encodeVarint(binding.variableID.sessionID),
11785
+ ...encodeVarint(binding.variableID.localID),
11786
+ 0,
11787
+ 0,
11788
+ 2,
11789
+ 3,
11790
+ 3,
11791
+ 4,
11792
+ 0,
11793
+ 0
11794
+ ];
11795
+ const varHex = Buffer.from(varBytes).toString("hex");
11796
+ const beforeVar = hex2.slice(0, insertPoint);
11797
+ let afterIdx = insertPoint;
11798
+ if (hex2.slice(afterIdx, afterIdx + 2) === "00") {
11799
+ afterIdx += 2;
11800
+ }
11801
+ const afterVar = hex2.slice(afterIdx);
11802
+ return beforeVar + varHex + afterVar;
11803
+ }
11647
11804
  var compiledSchema = null;
11648
11805
  var init_codec = __esm(() => {
11649
11806
  init_kiwi_esm();
@@ -26796,6 +26953,20 @@ new Elysia().ws("/plugin", {
26796
26953
  const { client, sessionID } = await getMultiplayerConnection(fileKey);
26797
26954
  consola.info(`render: ${nodeChanges.length} nodes to ${fileKey}`);
26798
26955
  await client.sendNodeChangesSync(nodeChanges);
26956
+ if (sendToPlugin) {
26957
+ const rootId = `${nodeChanges[0].guid.sessionID}:${nodeChanges[0].guid.localID}`;
26958
+ const layoutId = crypto.randomUUID();
26959
+ try {
26960
+ await new Promise((resolve, reject) => {
26961
+ const timeout = setTimeout(() => {
26962
+ pendingRequests.delete(layoutId);
26963
+ reject(new Error("Layout trigger timeout"));
26964
+ }, 5000);
26965
+ pendingRequests.set(layoutId, { resolve: () => resolve(), reject, timeout });
26966
+ sendToPlugin(JSON.stringify({ id: layoutId, command: "trigger-layout", args: { nodeId: rootId } }));
26967
+ });
26968
+ } catch {}
26969
+ }
26799
26970
  const ids = nodeChanges.map((nc) => ({
26800
26971
  id: `${nc.guid.sessionID}:${nc.guid.localID}`,
26801
26972
  name: nc.name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dannote/figma-use",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Control Figma from the command line. Full read/write access for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1184,10 +1184,34 @@
1184
1184
  const fn = new AsyncFunction("figma", wrappedCode);
1185
1185
  return yield fn(figma);
1186
1186
  }
1187
+ // ==================== LAYOUT ====================
1188
+ case "trigger-layout": {
1189
+ const { nodeId } = args;
1190
+ const root = yield figma.getNodeByIdAsync(nodeId);
1191
+ if (!root) return null;
1192
+ const triggerRecursive = (node) => {
1193
+ if ("layoutMode" in node && node.layoutMode !== "NONE" && "resize" in node) {
1194
+ const w = node.width;
1195
+ const h = node.height;
1196
+ node.resize(w + 0.01, h + 0.01);
1197
+ node.resize(w, h);
1198
+ }
1199
+ if ("children" in node) {
1200
+ for (const child of node.children) {
1201
+ triggerRecursive(child);
1202
+ }
1203
+ }
1204
+ };
1205
+ triggerRecursive(root);
1206
+ return { triggered: true };
1207
+ }
1187
1208
  // ==================== VARIABLES ====================
1188
1209
  case "get-variables": {
1189
- const { type } = args;
1210
+ const { type, simple } = args;
1190
1211
  const variables = yield figma.variables.getLocalVariablesAsync(type);
1212
+ if (simple) {
1213
+ return variables.map((v) => ({ id: v.id, name: v.name }));
1214
+ }
1191
1215
  return variables.map((v) => serializeVariable(v));
1192
1216
  }
1193
1217
  case "get-variable": {
@@ -1234,6 +1258,32 @@
1234
1258
  }
1235
1259
  return serializeNode(node);
1236
1260
  }
1261
+ case "bind-fill-variable": {
1262
+ const { nodeId, variableId, paintIndex = 0 } = args;
1263
+ const node = yield figma.getNodeByIdAsync(nodeId);
1264
+ if (!node) throw new Error("Node not found");
1265
+ if (!("fills" in node)) throw new Error("Node does not have fills");
1266
+ const variable = yield figma.variables.getVariableByIdAsync(variableId);
1267
+ if (!variable) throw new Error("Variable not found");
1268
+ const fills = node.fills;
1269
+ if (!fills[paintIndex]) throw new Error("Paint not found at index " + paintIndex);
1270
+ const newFill = figma.variables.setBoundVariableForPaint(fills[paintIndex], "color", variable);
1271
+ node.fills = [...fills.slice(0, paintIndex), newFill, ...fills.slice(paintIndex + 1)];
1272
+ return serializeNode(node);
1273
+ }
1274
+ case "bind-stroke-variable": {
1275
+ const { nodeId, variableId, paintIndex = 0 } = args;
1276
+ const node = yield figma.getNodeByIdAsync(nodeId);
1277
+ if (!node) throw new Error("Node not found");
1278
+ if (!("strokes" in node)) throw new Error("Node does not have strokes");
1279
+ const variable = yield figma.variables.getVariableByIdAsync(variableId);
1280
+ if (!variable) throw new Error("Variable not found");
1281
+ const strokes = node.strokes;
1282
+ if (!strokes[paintIndex]) throw new Error("Paint not found at index " + paintIndex);
1283
+ const newStroke = figma.variables.setBoundVariableForPaint(strokes[paintIndex], "color", variable);
1284
+ node.strokes = [...strokes.slice(0, paintIndex), newStroke, ...strokes.slice(paintIndex + 1)];
1285
+ return serializeNode(node);
1286
+ }
1237
1287
  // ==================== VARIABLE COLLECTIONS ====================
1238
1288
  case "get-variable-collections": {
1239
1289
  const collections = yield figma.variables.getLocalVariableCollectionsAsync();