figma-local 1.2.0 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +70 -15
  2. package/package.json +1 -1
  3. package/src/index.js +159 -5
package/README.md CHANGED
@@ -99,32 +99,66 @@ npm install && npm install -g .
99
99
 
100
100
  ## Setup (one time only)
101
101
 
102
- ### 1. Import the Figma plugin
102
+ ### Step 1 Install the CLI
103
103
 
104
- 1. Open **Figma Desktop**
105
- 2. Hamburger menu → **Plugins → Development → Import plugin from manifest...**
106
- 3. Navigate to the `plugin/` folder in this repo (or `$(npm root -g)/figma-local/plugin/`)
107
- 4. Select `manifest.json` → click **Open**
108
- 5. Right-click **Figma Local** in the plugin list → **Add to toolbar**
104
+ Pick one of the install methods above (npm recommended):
109
105
 
110
- ### 2. Connect
106
+ ```bash
107
+ npm install -g figma-local
108
+ ```
109
+
110
+ Verify it worked:
111
111
 
112
112
  ```bash
113
- fig-start --safe
113
+ fig --help
114
+ ```
115
+
116
+ ### Step 2 — Import the Figma plugin into Figma Desktop
117
+
118
+ The plugin lets figma-local talk to Figma. You only need to do this once per Figma account.
119
+
120
+ 1. Open **Figma Desktop** (not the browser — the desktop app)
121
+ 2. Open any design file
122
+ 3. Click the **hamburger menu** (☰) in the top-left corner
123
+ 4. Go to **Plugins → Development → Import plugin from manifest...**
124
+ 5. In the file picker, navigate to the plugin folder:
125
+ - If you installed via **npm**: run `echo "$(npm root -g)/figma-local/plugin"` in your terminal to get the path, then navigate there
126
+ - If you cloned the **repo**: go to `figma-local/plugin/` in the cloned folder
127
+ 6. Select `manifest.json` → click **Open**
128
+ 7. You should see **"Figma Local"** appear in your plugin list
129
+ 8. *(Optional but recommended)* Right-click **Figma Local** in the plugin list → **Add to toolbar** for one-click access
130
+
131
+ ### Step 3 — Connect and verify
132
+
133
+ 1. In Figma, start the plugin: **Plugins → Development → Figma Local** (or click it in your toolbar)
134
+ 2. You should see a small widget that says **"Figma Local"** with a connecting status
135
+ 3. In your terminal, run:
136
+
137
+ ```bash
138
+ fig connect --safe
114
139
  ```
115
140
 
116
- This starts the daemon, waits for you to click Figma Local in Figma, then launches Claude Code.
141
+ 4. The plugin widget should show a **green dot** and say **"Connected"**
142
+ 5. Try reading your canvas:
143
+
144
+ ```bash
145
+ fig read
146
+ ```
147
+
148
+ If you see a list of frames, you're all set!
117
149
 
118
150
  ---
119
151
 
120
152
  ## Every session after that
121
153
 
122
- ```
123
- 1. Open Figma click Figma Local in the toolbar
124
- 2. In terminal: fig-start --safe
154
+ ```bash
155
+ # 1. Open Figma Desktop and your design file
156
+ # 2. Click "Figma Local" in the toolbar (or Plugins → Development → Figma Local)
157
+ # 3. In your terminal:
158
+ fig-start --safe
125
159
  ```
126
160
 
127
- Claude Code reads `CLAUDE.md` and knows every command automatically.
161
+ This connects to Figma and launches Claude Code. Claude reads `CLAUDE.md` and knows every `fig` command automatically.
128
162
 
129
163
  ---
130
164
 
@@ -255,13 +289,34 @@ fig prompt "Login" \
255
289
 
256
290
  Generates ~45 tokens of structured text instead of attaching a Figma frame (300–500+ hidden tokens). **91–97% smaller input, more consistent AI output.**
257
291
 
292
+ ### Screenshots
293
+
294
+ ```bash
295
+ fig screenshot # Screenshot current selection
296
+ fig screenshot --node "123:456" # Screenshot a specific node
297
+ fig screenshot --link "https://..." # Screenshot from a Figma link
298
+ fig screenshot -o design.png -s 2 # Save to file at 2x scale
299
+ fig screenshot -f svg -o icon.svg # Export as SVG
300
+ ```
301
+
258
302
  ### Verify & compare
259
303
 
260
304
  ```bash
261
- fig verify # Screenshot of selection for AI review
262
- fig verify --compare "https://..." # Diff prototype vs Figma design → correction prompts
305
+ fig verify # Screenshot of selection for AI review
306
+ fig verify --link "https://..." # Verify from a Figma link
307
+ fig verify --node "123:456" # Verify a specific node
308
+ fig verify --compare "https://..." # Diff prototype vs Figma design → correction prompts
309
+
310
+ # Visual comparison between any two sources
311
+ fig compare --a selection --b "123:456" # Compare selection vs a node
312
+ fig compare --a design.png --b "123:456" # Compare a screenshot file vs a Figma node
313
+ fig compare --a design.png --b coded.png # Compare two screenshot files
314
+ fig compare --a-link "https://..." --b-link "https://..." # Compare two Figma links
315
+ fig compare --a "123:456" --b "789:012" # Compare two nodes by ID
263
316
  ```
264
317
 
318
+ Sources for `--a` and `--b` can be: `selection`, a node ID (`123:456`), or a file path (`screenshot.png`). Use `--a-link` / `--b-link` for Figma selection URLs.
319
+
265
320
  ### Export
266
321
 
267
322
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-local",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Control Figma Desktop with Claude Code. Smart read, write, and AI-prompt export. No API key required.",
5
5
  "author": "elvke",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -5205,18 +5205,30 @@ const exp = program
5205
5205
 
5206
5206
  exp
5207
5207
  .command('screenshot')
5208
- .description('Take a screenshot of selected node or current page')
5208
+ .description('Take a screenshot of selected node, specific node, or from a Figma link')
5209
5209
  .option('-o, --output <file>', 'Output file', 'screenshot.png')
5210
5210
  .option('-s, --scale <number>', 'Export scale (1-4)', '2')
5211
5211
  .option('-f, --format <format>', 'Format: png, jpg, svg, pdf', 'png')
5212
+ .option('--node <id>', 'Screenshot a specific node by ID')
5213
+ .option('--link <url>', 'Screenshot a node from a Figma selection link')
5212
5214
  .action((options) => {
5213
5215
  checkConnection();
5214
5216
  const format = options.format.toUpperCase();
5215
5217
  const scale = parseFloat(options.scale);
5218
+ let nodeResolver;
5219
+ if (options.link) {
5220
+ const nodeId = parseNodeIdFromLink(options.link);
5221
+ if (!nodeId) { console.error(chalk.red('✗'), 'Could not parse node ID from link'); process.exit(1); }
5222
+ nodeResolver = `node = await figma.getNodeByIdAsync('${nodeId}');`;
5223
+ } else if (options.node) {
5224
+ nodeResolver = `node = await figma.getNodeByIdAsync('${options.node}');`;
5225
+ } else {
5226
+ nodeResolver = `const sel = figma.currentPage.selection; node = sel.length > 0 ? sel[0] : figma.currentPage;`;
5227
+ }
5216
5228
  const code = `(async () => {
5217
- const sel = figma.currentPage.selection;
5218
- const node = sel.length > 0 ? sel[0] : figma.currentPage;
5219
- if (!node) return { error: 'No page or selection' };
5229
+ let node;
5230
+ ${nodeResolver}
5231
+ if (!node) return { error: 'No node found' };
5220
5232
  if (!('exportAsync' in node)) return { error: 'Node cannot be exported' };
5221
5233
  const bytes = await node.exportAsync({ format: '${format}', constraint: { type: 'SCALE', value: ${scale} } });
5222
5234
  return {
@@ -5333,14 +5345,25 @@ program
5333
5345
  .option('--save [path]', 'Save as PNG file (default: /tmp/figma-verify-{id}.png)')
5334
5346
  .option('--compare <url>', 'Compare against a prototype/preview URL and generate correction prompts')
5335
5347
  .option('--compare-save <path>', 'Save prototype screenshot to this path when using --compare')
5348
+ .option('--link <url>', 'Verify a node from a Figma selection link')
5349
+ .option('--node <id>', 'Verify a specific node by ID')
5336
5350
  .action(async (nodeId, options) => {
5337
5351
  checkConnection();
5338
5352
  const scale = parseFloat(options.scale);
5339
5353
  const maxDim = parseInt(options.max);
5340
5354
 
5355
+ // Resolve node: --link > --node > positional nodeId > selection
5356
+ let resolvedNodeId = nodeId;
5357
+ if (options.link) {
5358
+ resolvedNodeId = parseNodeIdFromLink(options.link);
5359
+ if (!resolvedNodeId) { console.error(chalk.red('✗'), 'Could not parse node ID from link'); process.exit(1); }
5360
+ } else if (options.node) {
5361
+ resolvedNodeId = options.node;
5362
+ }
5363
+
5341
5364
  const code = `(async () => {
5342
5365
  let node;
5343
- ${nodeId ? `node = await figma.getNodeByIdAsync('${nodeId}');` : `
5366
+ ${resolvedNodeId ? `node = await figma.getNodeByIdAsync('${resolvedNodeId}');` : `
5344
5367
  const sel = figma.currentPage.selection;
5345
5368
  node = sel.length > 0 ? sel[0] : null;
5346
5369
  `}
@@ -5441,6 +5464,137 @@ program
5441
5464
  }
5442
5465
  });
5443
5466
 
5467
+ // ============ COMPARE (Visual Comparison) ============
5468
+
5469
+ function exportNodeScreenshot(nodeResolver, scale, maxDim) {
5470
+ const code = `(async () => {
5471
+ let node;
5472
+ ${nodeResolver}
5473
+ if (!node) return { error: 'No node found' };
5474
+ if (!('exportAsync' in node)) return { error: 'Node cannot be exported' };
5475
+ const nodeWidth = node.width || 100;
5476
+ const nodeHeight = node.height || 100;
5477
+ let finalScale = ${scale};
5478
+ const maxNodeDim = Math.max(nodeWidth, nodeHeight);
5479
+ if (maxNodeDim * finalScale > ${maxDim}) finalScale = ${maxDim} / maxNodeDim;
5480
+ if (maxNodeDim * finalScale > 7500) finalScale = 7500 / maxNodeDim;
5481
+ const bytes = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: finalScale } });
5482
+ return {
5483
+ name: node.name,
5484
+ id: node.id,
5485
+ type: node.type,
5486
+ width: Math.round(nodeWidth * finalScale),
5487
+ height: Math.round(nodeHeight * finalScale),
5488
+ originalWidth: Math.round(nodeWidth),
5489
+ originalHeight: Math.round(nodeHeight),
5490
+ bytes: Array.from(bytes)
5491
+ };
5492
+ })()`;
5493
+ return figmaEvalSync(code);
5494
+ }
5495
+
5496
+ program
5497
+ .command('compare')
5498
+ .description('Compare two things visually: screenshots, Figma nodes, or a mix. Outputs both images for AI analysis.')
5499
+ .option('--a <source>', 'First source: file path, node ID, or "selection"', 'selection')
5500
+ .option('--b <source>', 'Second source: file path, node ID, or Figma link')
5501
+ .option('--a-link <url>', 'First source from a Figma selection link')
5502
+ .option('--b-link <url>', 'Second source from a Figma selection link')
5503
+ .option('-s, --scale <number>', 'Export scale for Figma nodes', '1')
5504
+ .option('--max <pixels>', 'Max dimension for exports', '2000')
5505
+ .option('--save-dir <dir>', 'Directory to save comparison images', '/tmp')
5506
+ .addHelpText('after', `
5507
+ Examples:
5508
+ fig compare --a selection --b "123:456" Compare selection vs a node
5509
+ fig compare --a design.png --b "123:456" Compare a screenshot file vs a Figma node
5510
+ fig compare --a design.png --b coded.png Compare two screenshot files
5511
+ fig compare --a-link "https://..." --b-link "https://..." Compare two Figma links
5512
+ fig compare --a selection --b-link "https://..." Compare selection vs a Figma link
5513
+ fig compare --a "123:456" --b "789:012" Compare two nodes by ID
5514
+ `)
5515
+ .action((options) => {
5516
+ checkConnection();
5517
+ const scale = parseFloat(options.scale);
5518
+ const maxDim = parseInt(options.max);
5519
+ const saveDir = options.saveDir;
5520
+ const timestamp = Date.now();
5521
+
5522
+ function resolveSource(source, linkOpt, label) {
5523
+ // If it's a --link option
5524
+ if (linkOpt) {
5525
+ const nodeId = parseNodeIdFromLink(linkOpt);
5526
+ if (!nodeId) { console.error(chalk.red('✗'), `Could not parse node ID from ${label} link`); process.exit(1); }
5527
+ const result = exportNodeScreenshot(`node = await figma.getNodeByIdAsync('${nodeId}');`, scale, maxDim);
5528
+ if (result.error) { console.error(chalk.red('✗'), `${label}: ${result.error}`); process.exit(1); }
5529
+ const filePath = `${saveDir}/figma-compare-${label}-${timestamp}.png`;
5530
+ writeFileSync(filePath, Buffer.from(result.bytes));
5531
+ return { type: 'figma-node', name: result.name, id: result.id, nodeType: result.type, width: result.width, height: result.height, originalWidth: result.originalWidth, originalHeight: result.originalHeight, path: filePath };
5532
+ }
5533
+
5534
+ // File path (existing screenshot)
5535
+ if (source && existsSync(source)) {
5536
+ return { type: 'file', name: source.split('/').pop(), path: source };
5537
+ }
5538
+
5539
+ // "selection"
5540
+ if (source === 'selection') {
5541
+ const result = exportNodeScreenshot(`const sel = figma.currentPage.selection; node = sel.length > 0 ? sel[0] : null;`, scale, maxDim);
5542
+ if (result.error) { console.error(chalk.red('✗'), `${label}: ${result.error}`); process.exit(1); }
5543
+ const filePath = `${saveDir}/figma-compare-${label}-${timestamp}.png`;
5544
+ writeFileSync(filePath, Buffer.from(result.bytes));
5545
+ return { type: 'figma-node', name: result.name, id: result.id, nodeType: result.type, width: result.width, height: result.height, originalWidth: result.originalWidth, originalHeight: result.originalHeight, path: filePath };
5546
+ }
5547
+
5548
+ // Node ID (contains ":")
5549
+ if (source && source.includes(':')) {
5550
+ const result = exportNodeScreenshot(`node = await figma.getNodeByIdAsync('${source}');`, scale, maxDim);
5551
+ if (result.error) { console.error(chalk.red('✗'), `${label}: ${result.error}`); process.exit(1); }
5552
+ const filePath = `${saveDir}/figma-compare-${label}-${timestamp}.png`;
5553
+ writeFileSync(filePath, Buffer.from(result.bytes));
5554
+ return { type: 'figma-node', name: result.name, id: result.id, nodeType: result.type, width: result.width, height: result.height, originalWidth: result.originalWidth, originalHeight: result.originalHeight, path: filePath };
5555
+ }
5556
+
5557
+ console.error(chalk.red('✗'), `${label}: "${source}" is not a valid file path, node ID, or "selection"`);
5558
+ process.exit(1);
5559
+ }
5560
+
5561
+ const sourceA = resolveSource(options.a, options.aLink, 'a');
5562
+ const sourceB = resolveSource(options.b, options.bLink, 'b');
5563
+
5564
+ console.log(chalk.bold('\n## Visual Comparison\n'));
5565
+ console.log(chalk.cyan('Source A:'), sourceA.name, sourceA.type === 'figma-node' ? `(${sourceA.nodeType} ${sourceA.id}, ${sourceA.originalWidth}x${sourceA.originalHeight})` : '(file)');
5566
+ console.log(chalk.cyan('Source B:'), sourceB.name, sourceB.type === 'figma-node' ? `(${sourceB.nodeType} ${sourceB.id}, ${sourceB.originalWidth}x${sourceB.originalHeight})` : '(file)');
5567
+ console.log('');
5568
+ console.log(chalk.green('Image A:'), sourceA.path);
5569
+ console.log(chalk.green('Image B:'), sourceB.path);
5570
+ console.log('');
5571
+
5572
+ // Output structured data for AI agents
5573
+ console.log(JSON.stringify({
5574
+ mode: 'visual-comparison',
5575
+ sourceA: { ...sourceA, bytes: undefined },
5576
+ sourceB: { ...sourceB, bytes: undefined },
5577
+ instructions: [
5578
+ `1. Open and examine Image A: ${sourceA.path}`,
5579
+ `2. Open and examine Image B: ${sourceB.path}`,
5580
+ '3. Compare them visually for differences in:',
5581
+ ' - Layout and spacing (padding, margins, gaps)',
5582
+ ' - Colors (backgrounds, text, borders)',
5583
+ ' - Typography (font size, weight, line-height)',
5584
+ ' - Border radius and shadows',
5585
+ ' - Missing or extra elements',
5586
+ ' - Alignment and positioning',
5587
+ '4. Output a structured gap report with specific differences',
5588
+ '5. For each difference, provide the exact values from both sources',
5589
+ ],
5590
+ gapReportTemplate: {
5591
+ matches: '(list elements that match between A and B)',
5592
+ differences: '(table: element | property | value_in_A | value_in_B | severity)',
5593
+ summary: '(brief overall assessment: how closely do they match)',
5594
+ }
5595
+ }, null, 2));
5596
+ });
5597
+
5444
5598
  // ============ EVAL ============
5445
5599
 
5446
5600
  program