figma-local 1.0.0 → 1.2.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/src/index.js CHANGED
@@ -1039,7 +1039,7 @@ program
1039
1039
 
1040
1040
  // Show plugin setup instructions
1041
1041
  console.log(chalk.hex('#FF6B35')('\n ┌─────────────────────────────────────────────────────┐'));
1042
- console.log(chalk.hex('#FF6B35')(' │') + chalk.white.bold(' Setup the FigCli plugin ') + chalk.hex('#FF6B35')('│'));
1042
+ console.log(chalk.hex('#FF6B35')(' │') + chalk.white.bold(' Setup the Figma Local plugin ') + chalk.hex('#FF6B35')('│'));
1043
1043
  console.log(chalk.hex('#FF6B35')(' └─────────────────────────────────────────────────────┘\n'));
1044
1044
 
1045
1045
  console.log(chalk.white.bold(' ONE-TIME SETUP:\n'));
@@ -1049,7 +1049,7 @@ program
1049
1049
  console.log(chalk.cyan(' 4. ') + chalk.white('Click ') + chalk.yellow('Open') + chalk.white(' — plugin is now installed!\n'));
1050
1050
 
1051
1051
  console.log(chalk.white.bold(' EACH SESSION:\n'));
1052
- console.log(chalk.cyan(' → ') + chalk.white('In Figma: ') + chalk.yellow('Plugins → Development → FigCli\n'));
1052
+ console.log(chalk.cyan(' → ') + chalk.white('In Figma: ') + chalk.yellow('Plugins → Development → Figma Local\n'));
1053
1053
 
1054
1054
  console.log(chalk.gray(' 💡 Tip: Right-click plugin → "Add to toolbar" for one-click access\n'));
1055
1055
 
@@ -6764,7 +6764,31 @@ node
6764
6764
  const b = Array.isArray(binding) ? binding[0] : binding;
6765
6765
  if (b && b.id) {
6766
6766
  const variable = figma.variables.getVariableById(b.id);
6767
- bindings[prop] = variable ? variable.name : b.id;
6767
+ if (variable) {
6768
+ const info = { name: variable.name };
6769
+ try {
6770
+ const modeIds = Object.keys(variable.valuesByMode);
6771
+ if (modeIds.length > 0) {
6772
+ const val = variable.valuesByMode[modeIds[0]];
6773
+ if (val !== undefined) {
6774
+ if (typeof val === 'object' && val.r !== undefined) {
6775
+ const r = Math.round((val.r||0)*255), g = Math.round((val.g||0)*255), bl = Math.round((val.b||0)*255);
6776
+ info.value = '#' + r.toString(16).padStart(2,'0') + g.toString(16).padStart(2,'0') + bl.toString(16).padStart(2,'0');
6777
+ } else if (typeof val === 'object' && val.id) {
6778
+ try {
6779
+ const alias = figma.variables.getVariableById(val.id);
6780
+ info.value = alias ? alias.name + ' (alias)' : 'alias';
6781
+ } catch(e) { info.value = 'alias'; }
6782
+ } else {
6783
+ info.value = val;
6784
+ }
6785
+ }
6786
+ }
6787
+ } catch(e) {}
6788
+ bindings[prop] = info;
6789
+ } else {
6790
+ bindings[prop] = { name: b.id };
6791
+ }
6768
6792
  }
6769
6793
  }
6770
6794
  }
@@ -8354,21 +8378,100 @@ blocksCmd
8354
8378
 
8355
8379
  program
8356
8380
  .command('read [frameName]')
8357
- .description('Extract design info in lean structured format (staged, token-efficient)')
8358
- .option('--lean', 'Output compact text block instead of raw JSON (default: true)')
8359
- .option('--json', 'Output raw JSON instead of text block')
8360
- .option('--tokens', 'Include only used design tokens (skips structure)')
8361
- .option('--stage <n>', 'Run only stage 1 (metadata), 2 (structure), or 3 (tokens)', null)
8381
+ .description('Read your Figma canvas list all frames, or dive into one for layout, components, and tokens')
8382
+ .option('--json', 'Output raw JSON instead of formatted text')
8383
+ .option('--tokens', 'Show only the design tokens (colors, spacing, etc.) used in the frame')
8384
+ .option('--selection', 'Read whatever is currently selected in Figma')
8385
+ .option('--link <url>', 'Read a specific node from a Figma selection link (e.g. copied via "Copy link to selection")')
8386
+ .option('--stage <n>', 'Run a specific stage: 1 (list frames), 2 (structure), 3 (tokens)')
8387
+ .addHelpText('after', `
8388
+ Examples:
8389
+ fig read List all frames on the current page
8390
+ fig read "Login Screen" Get layout, components, and tokens for the Login Screen frame
8391
+ fig read "Login Screen" --tokens Show only the design tokens used by Login Screen
8392
+ fig read --selection Read the node you currently have selected in Figma
8393
+ fig read --link "https://..." Read a node from a Figma selection link
8394
+ fig read --json Output raw JSON (useful for piping to other tools)
8395
+ `)
8362
8396
  .action(async (frameName, options) => {
8363
8397
  checkConnection();
8364
- const spinner = ora('Reading design (stage 1: metadata)...').start();
8398
+ const spinner = ora('Reading design...').start();
8365
8399
 
8366
8400
  try {
8401
+ // --selection: read the currently selected node in Figma
8402
+ if (options.selection) {
8403
+ spinner.text = 'Reading current selection...';
8404
+ const selectionCode = `(function() {
8405
+ const sel = figma.currentPage.selection;
8406
+ if (!sel || sel.length === 0) return { error: 'Nothing selected in Figma. Select a frame or layer first.' };
8407
+ const node = sel[0];
8408
+ function walk(n, depth) {
8409
+ if (depth > 4) return { type: n.type, name: n.name, truncated: true };
8410
+ const obj = { type: n.type, name: n.name };
8411
+ if (n.width) obj.w = Math.round(n.width);
8412
+ if (n.height) obj.h = Math.round(n.height);
8413
+ if (n.type === 'TEXT') obj.text = n.characters.slice(0, 100);
8414
+ if (n.fills && n.fills.length) obj.fills = n.fills.map(f => f.type === 'SOLID' ? { r: Math.round(f.color.r*255), g: Math.round(f.color.g*255), b: Math.round(f.color.b*255) } : { type: f.type });
8415
+ if (n.cornerRadius) obj.radius = n.cornerRadius;
8416
+ if (n.children) obj.children = n.children.slice(0, 20).map(c => walk(c, depth + 1));
8417
+ return obj;
8418
+ }
8419
+ return { selected: sel.length, node: walk(node, 0) };
8420
+ })()`;
8421
+ const result = await daemonExec('eval', { code: selectionCode });
8422
+ if (result.error) {
8423
+ spinner.fail(result.error);
8424
+ process.exit(1);
8425
+ }
8426
+ spinner.succeed(`Reading selection: ${result.node.name}`);
8427
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatSelectionResult(result));
8428
+ return;
8429
+ }
8430
+
8431
+ // --link: parse Figma URL and read a specific node by ID
8432
+ if (options.link) {
8433
+ spinner.text = 'Reading node from link...';
8434
+ // Figma links contain node-id as a query param like ?node-id=123:456 or ?node-id=123-456
8435
+ const url = options.link;
8436
+ const nodeIdMatch = url.match(/node-id=([0-9]+-[0-9]+|[0-9]+:[0-9]+|[0-9]+%3A[0-9]+)/i);
8437
+ if (!nodeIdMatch) {
8438
+ spinner.fail('Could not find a node ID in that link. Copy the link from Figma via right-click → "Copy/Paste as" → "Copy link to selection".');
8439
+ process.exit(1);
8440
+ }
8441
+ // Normalize: Figma URLs use - or %3A, but the API expects :
8442
+ const nodeId = decodeURIComponent(nodeIdMatch[1]).replace('-', ':');
8443
+ const nodeCode = `(function() {
8444
+ const node = figma.getNodeById('${nodeId}');
8445
+ if (!node) return { error: 'Node ${nodeId} not found on the current page.' };
8446
+ function walk(n, depth) {
8447
+ if (depth > 4) return { type: n.type, name: n.name, truncated: true };
8448
+ const obj = { type: n.type, name: n.name };
8449
+ if (n.width) obj.w = Math.round(n.width);
8450
+ if (n.height) obj.h = Math.round(n.height);
8451
+ if (n.type === 'TEXT') obj.text = n.characters.slice(0, 100);
8452
+ if (n.fills && n.fills.length) obj.fills = n.fills.map(f => f.type === 'SOLID' ? { r: Math.round(f.color.r*255), g: Math.round(f.color.g*255), b: Math.round(f.color.b*255) } : { type: f.type });
8453
+ if (n.cornerRadius) obj.radius = n.cornerRadius;
8454
+ if (n.children) obj.children = n.children.slice(0, 20).map(c => walk(c, depth + 1));
8455
+ return obj;
8456
+ }
8457
+ return { nodeId: '${nodeId}', node: walk(node, 0) };
8458
+ })()`;
8459
+ const result = await daemonExec('eval', { code: nodeCode });
8460
+ if (result.error) {
8461
+ spinner.fail(result.error);
8462
+ process.exit(1);
8463
+ }
8464
+ spinner.succeed(`Reading node: ${result.node.name} (${nodeId})`);
8465
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatSelectionResult(result));
8466
+ return;
8467
+ }
8468
+
8367
8469
  // Stage 1: metadata — always run, cheapest call
8470
+ spinner.text = 'Scanning canvas...';
8368
8471
  const metadata = await daemonExec('eval', { code: STAGE1_METADATA });
8369
8472
 
8370
8473
  if (options.stage === '1') {
8371
- spinner.succeed('Stage 1 complete');
8474
+ spinner.succeed('Canvas scanned');
8372
8475
  console.log(options.json ? JSON.stringify(metadata, null, 2) : formatStage1(metadata));
8373
8476
  return;
8374
8477
  }
@@ -8387,44 +8490,41 @@ program
8387
8490
  } else if (metadata.frames.length === 1) {
8388
8491
  targetFrame = metadata.frames[0];
8389
8492
  } else {
8390
- spinner.succeed('Stage 1 complete multiple frames found');
8391
- console.log('\nAvailable frames (use read <frameName> to focus):');
8392
- metadata.frames.forEach(f => console.log(` • ${f.name} ${f.w}x${f.h}`));
8393
- console.log('\nOr: read "Frame Name" to extract structure + tokens for a specific frame.');
8493
+ spinner.succeed(`Found ${metadata.frames.length} frames on "${metadata.page}"`);
8494
+ console.log('');
8495
+ metadata.frames.forEach(f => console.log(` • ${f.name} ${f.w}x${f.h} [${f.type}]`));
8496
+ console.log(`\nTo read a specific frame:\n fig read "${metadata.frames[0].name}"`);
8394
8497
  return;
8395
8498
  }
8396
8499
 
8397
- // Stage 2: frame structure — only the target frame
8398
- spinner.text = `Stage 2: reading structure of "${targetFrame.name}"...`;
8500
+ // Stage 2: frame structure
8501
+ spinner.text = `Reading "${targetFrame.name}"...`;
8399
8502
  const frameStructure = await daemonExec('eval', { code: buildFrameStructureCode(targetFrame.id) });
8400
8503
 
8401
8504
  if (options.stage === '2') {
8402
- spinner.succeed('Stage 2 complete');
8505
+ spinner.succeed('Structure extracted');
8403
8506
  console.log(options.json ? JSON.stringify(frameStructure, null, 2) : JSON.stringify(frameStructure, null, 2));
8404
8507
  return;
8405
8508
  }
8406
8509
 
8407
8510
  if (options.tokens) {
8408
- // Skip to stage 3 only
8409
- spinner.text = 'Stage 3: extracting used tokens only...';
8511
+ spinner.text = 'Extracting design tokens...';
8410
8512
  const tokens = await daemonExec('eval', { code: buildUsedTokensCode(targetFrame.id) });
8411
- spinner.succeed(`Stage 3 complete — ${Object.keys(tokens).length} tokens used in this frame`);
8513
+ spinner.succeed(`${Object.keys(tokens).length} tokens used in "${targetFrame.name}"`);
8412
8514
  console.log(options.json ? JSON.stringify(tokens, null, 2) : formatTokensOnly(tokens));
8413
8515
  return;
8414
8516
  }
8415
8517
 
8416
- // Stage 3: used tokens — parallel with structure already done
8417
- spinner.text = 'Stage 3: extracting used tokens...';
8518
+ // Stage 3: used tokens
8519
+ spinner.text = 'Extracting tokens...';
8418
8520
  const tokens = await daemonExec('eval', { code: buildUsedTokensCode(targetFrame.id) });
8419
8521
 
8420
8522
  spinner.succeed(`Read complete — ${targetFrame.name}`);
8421
8523
  console.log('');
8422
8524
 
8423
- // Format and output
8424
8525
  if (options.json) {
8425
8526
  console.log(JSON.stringify({ metadata, frame: frameStructure, tokens }, null, 2));
8426
8527
  } else {
8427
- // Default: lean text block
8428
8528
  const ctx = formatLeanContext(metadata, frameStructure, tokens, targetFrame.name);
8429
8529
  console.log(ctx);
8430
8530
  }
@@ -8447,11 +8547,1658 @@ function formatTokensOnly(tokens) {
8447
8547
  keys.map(k => ` ${k}: ${tokens[k]}`).join('\n');
8448
8548
  }
8449
8549
 
8550
+ function formatSelectionResult(result) {
8551
+ const lines = [];
8552
+ function printNode(node, depth) {
8553
+ if (!node) return;
8554
+ const indent = ' '.repeat(depth);
8555
+ let line = `${indent}[${node.type}] ${node.name}`;
8556
+ if (node.w && node.h) line += ` ${node.w}x${node.h}`;
8557
+ if (node.text) line += ` "${node.text}"`;
8558
+ if (node.radius) line += ` radius=${node.radius}`;
8559
+ if (node.fills && node.fills.length) {
8560
+ const colors = node.fills.map(f => f.r !== undefined ? `rgb(${f.r},${f.g},${f.b})` : f.type).join(', ');
8561
+ line += ` fill=${colors}`;
8562
+ }
8563
+ if (node.truncated) line += ' ...';
8564
+ lines.push(line);
8565
+ if (node.children) node.children.forEach(c => printNode(c, depth + 1));
8566
+ }
8567
+ printNode(result.node, 0);
8568
+ return lines.join('\n');
8569
+ }
8570
+
8571
+ // ============ INSPECT — detailed design specs ============
8572
+
8573
+ const INSPECT_CODE = `(function() {
8574
+ var sel = figma.currentPage.selection;
8575
+ if (!sel || sel.length === 0) return { error: 'Nothing selected. Select a frame, component, or layer in Figma first.' };
8576
+
8577
+ var BASE = 16; // 1rem = 16px
8578
+
8579
+ function toRem(px) {
8580
+ return parseFloat((px / BASE).toFixed(3));
8581
+ }
8582
+
8583
+ function colorToHex(c) {
8584
+ var r = Math.round((c.r || 0) * 255);
8585
+ var g = Math.round((c.g || 0) * 255);
8586
+ var b = Math.round((c.b || 0) * 255);
8587
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
8588
+ }
8589
+
8590
+ function colorToRgba(c, opacity) {
8591
+ var r = Math.round((c.r || 0) * 255);
8592
+ var g = Math.round((c.g || 0) * 255);
8593
+ var b = Math.round((c.b || 0) * 255);
8594
+ if (opacity !== undefined && opacity < 1) {
8595
+ return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + parseFloat(opacity.toFixed(2)) + ')';
8596
+ }
8597
+ return 'rgb(' + r + ', ' + g + ', ' + b + ')';
8598
+ }
8599
+
8600
+ function inspectNode(node) {
8601
+ var spec = {
8602
+ name: node.name,
8603
+ type: node.type,
8604
+ };
8605
+
8606
+ // Dimensions
8607
+ if (node.width !== undefined) {
8608
+ spec.dimensions = {
8609
+ width: { px: Math.round(node.width), rem: toRem(node.width) },
8610
+ height: { px: Math.round(node.height), rem: toRem(node.height) }
8611
+ };
8612
+ }
8613
+
8614
+ // Position
8615
+ if (node.x !== undefined) {
8616
+ spec.position = {
8617
+ x: { px: Math.round(node.x), rem: toRem(node.x) },
8618
+ y: { px: Math.round(node.y), rem: toRem(node.y) }
8619
+ };
8620
+ }
8621
+
8622
+ // Auto layout / Flexbox
8623
+ if (node.layoutMode && node.layoutMode !== 'NONE') {
8624
+ spec.layout = {
8625
+ mode: node.layoutMode === 'HORIZONTAL' ? 'row' : 'column',
8626
+ gap: { px: node.itemSpacing || 0, rem: toRem(node.itemSpacing || 0) },
8627
+ padding: {
8628
+ top: { px: node.paddingTop || 0, rem: toRem(node.paddingTop || 0) },
8629
+ right: { px: node.paddingRight || 0, rem: toRem(node.paddingRight || 0) },
8630
+ bottom: { px: node.paddingBottom || 0, rem: toRem(node.paddingBottom || 0) },
8631
+ left: { px: node.paddingLeft || 0, rem: toRem(node.paddingLeft || 0) }
8632
+ },
8633
+ mainAxisAlign: node.primaryAxisAlignItems || 'MIN',
8634
+ crossAxisAlign: node.counterAxisAlignItems || 'MIN',
8635
+ wrap: node.layoutWrap === 'WRAP' ? true : false
8636
+ };
8637
+ if (node.primaryAxisSizingMode) spec.layout.mainAxisSizing = node.primaryAxisSizingMode;
8638
+ if (node.counterAxisSizingMode) spec.layout.crossAxisSizing = node.counterAxisSizingMode;
8639
+ }
8640
+
8641
+ // Corner radius
8642
+ if (node.cornerRadius !== undefined && node.cornerRadius !== 0) {
8643
+ if (typeof node.cornerRadius === 'number') {
8644
+ spec.borderRadius = { px: node.cornerRadius, rem: toRem(node.cornerRadius) };
8645
+ }
8646
+ }
8647
+ // Per-corner radius
8648
+ if (node.topLeftRadius || node.topRightRadius || node.bottomRightRadius || node.bottomLeftRadius) {
8649
+ spec.borderRadiusPerCorner = {
8650
+ topLeft: { px: node.topLeftRadius || 0, rem: toRem(node.topLeftRadius || 0) },
8651
+ topRight: { px: node.topRightRadius || 0, rem: toRem(node.topRightRadius || 0) },
8652
+ bottomRight: { px: node.bottomRightRadius || 0, rem: toRem(node.bottomRightRadius || 0) },
8653
+ bottomLeft: { px: node.bottomLeftRadius || 0, rem: toRem(node.bottomLeftRadius || 0) }
8654
+ };
8655
+ }
8656
+
8657
+ // Fills
8658
+ if (node.fills && node.fills.length > 0) {
8659
+ spec.fills = [];
8660
+ for (var i = 0; i < node.fills.length; i++) {
8661
+ var f = node.fills[i];
8662
+ if (f.visible === false) continue;
8663
+ var fillSpec = { type: f.type };
8664
+ if (f.type === 'SOLID' && f.color) {
8665
+ fillSpec.hex = colorToHex(f.color);
8666
+ fillSpec.rgba = colorToRgba(f.color, f.opacity);
8667
+ fillSpec.opacity = f.opacity !== undefined ? f.opacity : 1;
8668
+ }
8669
+ if (f.type === 'GRADIENT_LINEAR' || f.type === 'GRADIENT_RADIAL') {
8670
+ fillSpec.stops = f.gradientStops ? f.gradientStops.map(function(s) {
8671
+ return { color: colorToHex(s.color), position: parseFloat(s.position.toFixed(2)) };
8672
+ }) : [];
8673
+ }
8674
+ spec.fills.push(fillSpec);
8675
+ }
8676
+ }
8677
+
8678
+ // Strokes
8679
+ if (node.strokes && node.strokes.length > 0) {
8680
+ spec.strokes = [];
8681
+ for (var i = 0; i < node.strokes.length; i++) {
8682
+ var s = node.strokes[i];
8683
+ if (s.visible === false) continue;
8684
+ var strokeSpec = { type: s.type };
8685
+ if (s.type === 'SOLID' && s.color) {
8686
+ strokeSpec.hex = colorToHex(s.color);
8687
+ strokeSpec.rgba = colorToRgba(s.color, s.opacity);
8688
+ }
8689
+ spec.strokes.push(strokeSpec);
8690
+ }
8691
+ if (node.strokeWeight) spec.strokeWeight = { px: node.strokeWeight, rem: toRem(node.strokeWeight) };
8692
+ if (node.strokeAlign) spec.strokeAlign = node.strokeAlign;
8693
+ }
8694
+
8695
+ // Typography
8696
+ if (node.type === 'TEXT') {
8697
+ spec.typography = {};
8698
+ spec.typography.content = node.characters;
8699
+ if (node.fontName && node.fontName !== figma.mixed) {
8700
+ spec.typography.fontFamily = node.fontName.family;
8701
+ spec.typography.fontStyle = node.fontName.style;
8702
+ }
8703
+ if (node.fontSize && node.fontSize !== figma.mixed) {
8704
+ spec.typography.fontSize = { px: node.fontSize, rem: toRem(node.fontSize) };
8705
+ }
8706
+ if (node.fontWeight && node.fontWeight !== figma.mixed) {
8707
+ spec.typography.fontWeight = node.fontWeight;
8708
+ }
8709
+ if (node.lineHeight && node.lineHeight !== figma.mixed) {
8710
+ if (node.lineHeight.unit === 'PIXELS') {
8711
+ spec.typography.lineHeight = { px: node.lineHeight.value, rem: toRem(node.lineHeight.value) };
8712
+ } else if (node.lineHeight.unit === 'PERCENT') {
8713
+ spec.typography.lineHeight = { percent: node.lineHeight.value + '%' };
8714
+ } else {
8715
+ spec.typography.lineHeight = 'auto';
8716
+ }
8717
+ }
8718
+ if (node.letterSpacing && node.letterSpacing !== figma.mixed) {
8719
+ if (node.letterSpacing.unit === 'PIXELS') {
8720
+ spec.typography.letterSpacing = { px: node.letterSpacing.value, rem: toRem(node.letterSpacing.value) };
8721
+ } else if (node.letterSpacing.unit === 'PERCENT') {
8722
+ spec.typography.letterSpacing = { percent: node.letterSpacing.value + '%' };
8723
+ }
8724
+ }
8725
+ if (node.textAlignHorizontal) spec.typography.textAlign = node.textAlignHorizontal.toLowerCase();
8726
+ if (node.textDecoration && node.textDecoration !== 'NONE' && node.textDecoration !== figma.mixed) {
8727
+ spec.typography.textDecoration = node.textDecoration.toLowerCase();
8728
+ }
8729
+ if (node.textCase && node.textCase !== 'ORIGINAL' && node.textCase !== figma.mixed) {
8730
+ spec.typography.textTransform = node.textCase.toLowerCase();
8731
+ }
8732
+ }
8733
+
8734
+ // Effects (shadows, blur)
8735
+ if (node.effects && node.effects.length > 0) {
8736
+ spec.effects = [];
8737
+ for (var i = 0; i < node.effects.length; i++) {
8738
+ var e = node.effects[i];
8739
+ if (e.visible === false) continue;
8740
+ var effectSpec = { type: e.type };
8741
+ if (e.type === 'DROP_SHADOW' || e.type === 'INNER_SHADOW') {
8742
+ effectSpec.offset = {
8743
+ x: { px: e.offset.x, rem: toRem(e.offset.x) },
8744
+ y: { px: e.offset.y, rem: toRem(e.offset.y) }
8745
+ };
8746
+ effectSpec.blur = { px: e.radius, rem: toRem(e.radius) };
8747
+ effectSpec.spread = { px: e.spread || 0, rem: toRem(e.spread || 0) };
8748
+ if (e.color) effectSpec.color = colorToRgba(e.color, e.color.a);
8749
+ }
8750
+ if (e.type === 'LAYER_BLUR' || e.type === 'BACKGROUND_BLUR') {
8751
+ effectSpec.blur = { px: e.radius, rem: toRem(e.radius) };
8752
+ }
8753
+ spec.effects.push(effectSpec);
8754
+ }
8755
+ }
8756
+
8757
+ // Opacity
8758
+ if (node.opacity !== undefined && node.opacity < 1) {
8759
+ spec.opacity = parseFloat(node.opacity.toFixed(2));
8760
+ }
8761
+
8762
+ // Constraints
8763
+ if (node.constraints) {
8764
+ spec.constraints = {
8765
+ horizontal: node.constraints.horizontal,
8766
+ vertical: node.constraints.vertical
8767
+ };
8768
+ }
8769
+
8770
+ // Component info
8771
+ if (node.type === 'INSTANCE' && node.mainComponent) {
8772
+ spec.component = {
8773
+ name: node.mainComponent.name,
8774
+ id: node.mainComponent.id
8775
+ };
8776
+ if (node.mainComponent.parent && node.mainComponent.parent.type === 'COMPONENT_SET') {
8777
+ spec.component.set = node.mainComponent.parent.name;
8778
+ }
8779
+ }
8780
+ if (node.type === 'COMPONENT') {
8781
+ spec.component = { name: node.name, id: node.id, isMainComponent: true };
8782
+ }
8783
+
8784
+ // Variable bindings
8785
+ if (node.boundVariables) {
8786
+ var vars = {};
8787
+ var bindings = node.boundVariables;
8788
+ Object.keys(bindings).forEach(function(prop) {
8789
+ var binding = bindings[prop];
8790
+ if (!binding) return;
8791
+ var entries = Array.isArray(binding) ? binding : [binding];
8792
+ entries.forEach(function(b) {
8793
+ if (!b || !b.id) return;
8794
+ try {
8795
+ var v = figma.variables.getVariableById(b.id);
8796
+ if (v) {
8797
+ var info = { name: v.name };
8798
+ try {
8799
+ var modeIds = Object.keys(v.valuesByMode);
8800
+ if (modeIds.length > 0) {
8801
+ var val = v.valuesByMode[modeIds[0]];
8802
+ if (val !== undefined) {
8803
+ if (typeof val === 'object' && val.r !== undefined) {
8804
+ info.value = colorToHex(val);
8805
+ } else if (typeof val === 'object' && val.id) {
8806
+ try {
8807
+ var alias = figma.variables.getVariableById(val.id);
8808
+ if (alias) info.value = alias.name + ' (alias)';
8809
+ } catch(e2) { info.value = 'alias'; }
8810
+ } else {
8811
+ info.value = val;
8812
+ }
8813
+ }
8814
+ }
8815
+ if (modeIds.length > 1) {
8816
+ info.modes = {};
8817
+ for (var mi = 0; mi < modeIds.length; mi++) {
8818
+ var mVal = v.valuesByMode[modeIds[mi]];
8819
+ try {
8820
+ var collection = figma.variables.getVariableCollectionById(v.variableCollectionId);
8821
+ var modeName = collection ? collection.modes.find(function(m) { return m.modeId === modeIds[mi]; }) : null;
8822
+ var mKey = modeName ? modeName.name : modeIds[mi];
8823
+ if (typeof mVal === 'object' && mVal.r !== undefined) {
8824
+ info.modes[mKey] = colorToHex(mVal);
8825
+ } else if (typeof mVal === 'object' && mVal.id) {
8826
+ try {
8827
+ var aliasM = figma.variables.getVariableById(mVal.id);
8828
+ info.modes[mKey] = aliasM ? aliasM.name : 'alias';
8829
+ } catch(e3) { info.modes[mKey] = 'alias'; }
8830
+ } else {
8831
+ info.modes[mKey] = mVal;
8832
+ }
8833
+ } catch(e4) {}
8834
+ }
8835
+ }
8836
+ } catch(e5) {}
8837
+ vars[prop] = info;
8838
+ }
8839
+ } catch(e) {}
8840
+ });
8841
+ });
8842
+ if (Object.keys(vars).length > 0) spec.variableBindings = vars;
8843
+ }
8844
+
8845
+ // Children summary
8846
+ if (node.children && node.children.length > 0) {
8847
+ spec.childCount = node.children.length;
8848
+ spec.children = node.children.slice(0, 30).map(function(c) {
8849
+ return { name: c.name, type: c.type, w: c.width ? Math.round(c.width) : undefined, h: c.height ? Math.round(c.height) : undefined };
8850
+ });
8851
+ }
8852
+
8853
+ return spec;
8854
+ }
8855
+
8856
+ // Inspect all selected nodes (up to 10)
8857
+ var results = [];
8858
+ var count = Math.min(sel.length, 10);
8859
+ for (var i = 0; i < count; i++) {
8860
+ results.push(inspectNode(sel[i]));
8861
+ }
8862
+ return { count: sel.length, specs: results };
8863
+ })()`;
8864
+
8865
+ function buildInspectCodeForNode(nodeId) {
8866
+ return INSPECT_CODE.replace(
8867
+ "var sel = figma.currentPage.selection;\n if (!sel || sel.length === 0) return { error: 'Nothing selected. Select a frame, component, or layer in Figma first.' };",
8868
+ `var targetNode = figma.getNodeById('${nodeId}');\n if (!targetNode) return { error: 'Node ${nodeId} not found.' };\n var sel = [targetNode];`
8869
+ );
8870
+ }
8871
+
8872
+ function parseNodeIdFromLink(url) {
8873
+ const match = url.match(/node-id=([0-9]+-[0-9]+|[0-9]+:[0-9]+|[0-9]+%3A[0-9]+)/i);
8874
+ if (!match) return null;
8875
+ return decodeURIComponent(match[1]).replace('-', ':');
8876
+ }
8877
+
8878
+ program
8879
+ .command('inspect')
8880
+ .description('Get detailed design specs for the selected element — spacing, colors, typography, effects (all in px and rem)')
8881
+ .option('--json', 'Output raw JSON')
8882
+ .option('--node <id>', 'Inspect a specific node by ID instead of selection')
8883
+ .option('--link <url>', 'Inspect a node from a Figma selection link')
8884
+ .option('--deep', 'Also inspect all children recursively (useful for components)')
8885
+ .addHelpText('after', `
8886
+ Examples:
8887
+ fig inspect Inspect what you have selected in Figma
8888
+ fig inspect --node "123:456" Inspect a specific node by ID
8889
+ fig inspect --link "https://..." Inspect from a Figma selection link
8890
+ fig inspect --deep Inspect selection + all children
8891
+ fig inspect --json Output raw JSON for programmatic use
8892
+ `)
8893
+ .action(async (options) => {
8894
+ checkConnection();
8895
+ const spinner = ora('Inspecting...').start();
8896
+
8897
+ try {
8898
+ let code = INSPECT_CODE;
8899
+ if (options.link) {
8900
+ const nodeId = parseNodeIdFromLink(options.link);
8901
+ if (!nodeId) {
8902
+ spinner.fail('Could not find a node ID in that link. Copy via right-click → "Copy link to selection" in Figma.');
8903
+ process.exit(1);
8904
+ }
8905
+ code = buildInspectCodeForNode(nodeId);
8906
+ } else if (options.node) {
8907
+ code = buildInspectCodeForNode(options.node);
8908
+ }
8909
+
8910
+ const result = await daemonExec('eval', { code });
8911
+
8912
+ if (result.error) {
8913
+ spinner.fail(result.error);
8914
+ process.exit(1);
8915
+ }
8916
+
8917
+ // If --deep, also inspect all children
8918
+ if (options.deep && result.specs.length > 0 && result.specs[0].children) {
8919
+ spinner.text = 'Inspecting children...';
8920
+ for (const child of result.specs[0].children) {
8921
+ // We need the child's node ID — get it from the parent
8922
+ // Use a separate eval to get child IDs
8923
+ }
8924
+ // Collect child node IDs from the parent
8925
+ const parentName = result.specs[0].name;
8926
+ const childIdsCode = `(function() {
8927
+ var sel = figma.currentPage.selection;
8928
+ var node = sel && sel.length > 0 ? sel[0] : null;
8929
+ ${options.node ? `node = figma.getNodeById('${options.node}');` : ''}
8930
+ ${options.link ? `node = figma.getNodeById('${parseNodeIdFromLink(options.link)}');` : ''}
8931
+ if (!node || !node.children) return [];
8932
+ return node.children.slice(0, 30).map(function(c) { return c.id; });
8933
+ })()`;
8934
+ const childIds = await daemonExec('eval', { code: childIdsCode });
8935
+ if (Array.isArray(childIds)) {
8936
+ for (const cid of childIds) {
8937
+ const childResult = await daemonExec('eval', { code: buildInspectCodeForNode(cid) });
8938
+ if (childResult && childResult.specs) {
8939
+ result.specs.push(...childResult.specs);
8940
+ }
8941
+ }
8942
+ }
8943
+ }
8944
+
8945
+ spinner.succeed(`Inspected ${result.specs.length} element${result.specs.length > 1 ? 's' : ''}`);
8946
+
8947
+ if (options.json) {
8948
+ console.log(JSON.stringify(result, null, 2));
8949
+ } else {
8950
+ console.log('');
8951
+ for (const spec of result.specs) {
8952
+ console.log(formatInspectSpec(spec));
8953
+ console.log('');
8954
+ }
8955
+ }
8956
+ } catch (e) {
8957
+ spinner.fail(`Inspect failed: ${e.message}`);
8958
+ process.exit(1);
8959
+ }
8960
+ });
8961
+
8962
+ function formatInspectSpec(spec) {
8963
+ const lines = [];
8964
+ const px = (v) => typeof v === 'object' && v.px !== undefined ? v.px : v;
8965
+ const rem = (v) => typeof v === 'object' && v.rem !== undefined ? v.rem : null;
8966
+ const dual = (v) => {
8967
+ if (!v || typeof v !== 'object') return String(v);
8968
+ if (v.px !== undefined && v.rem !== undefined) return `${v.px}px (${v.rem}rem)`;
8969
+ if (v.percent) return v.percent;
8970
+ return String(v);
8971
+ };
8972
+
8973
+ // Header
8974
+ lines.push(chalk.bold(`${spec.name}`) + chalk.gray(` [${spec.type}]`));
8975
+ lines.push(chalk.gray('─'.repeat(50)));
8976
+
8977
+ // Dimensions
8978
+ if (spec.dimensions) {
8979
+ lines.push(chalk.cyan('Dimensions'));
8980
+ lines.push(` Width: ${dual(spec.dimensions.width)}`);
8981
+ lines.push(` Height: ${dual(spec.dimensions.height)}`);
8982
+ }
8983
+
8984
+ // Position
8985
+ if (spec.position) {
8986
+ lines.push(chalk.cyan('Position'));
8987
+ lines.push(` X: ${dual(spec.position.x)} Y: ${dual(spec.position.y)}`);
8988
+ }
8989
+
8990
+ // Layout
8991
+ if (spec.layout) {
8992
+ lines.push(chalk.cyan('Layout (Auto Layout)'));
8993
+ lines.push(` Direction: ${spec.layout.mode}`);
8994
+ lines.push(` Gap: ${dual(spec.layout.gap)}`);
8995
+ const p = spec.layout.padding;
8996
+ lines.push(` Padding: ${px(p.top)}px ${px(p.right)}px ${px(p.bottom)}px ${px(p.left)}px (${rem(p.top)}rem ${rem(p.right)}rem ${rem(p.bottom)}rem ${rem(p.left)}rem)`);
8997
+ lines.push(` Align: main=${spec.layout.mainAxisAlign} cross=${spec.layout.crossAxisAlign}`);
8998
+ if (spec.layout.wrap) lines.push(` Wrap: yes`);
8999
+ }
9000
+
9001
+ // Border radius
9002
+ if (spec.borderRadius) {
9003
+ lines.push(chalk.cyan('Border Radius'));
9004
+ lines.push(` ${dual(spec.borderRadius)}`);
9005
+ }
9006
+ if (spec.borderRadiusPerCorner) {
9007
+ lines.push(chalk.cyan('Border Radius (per corner)'));
9008
+ const c = spec.borderRadiusPerCorner;
9009
+ lines.push(` TL: ${dual(c.topLeft)} TR: ${dual(c.topRight)} BR: ${dual(c.bottomRight)} BL: ${dual(c.bottomLeft)}`);
9010
+ }
9011
+
9012
+ // Fills
9013
+ if (spec.fills && spec.fills.length > 0) {
9014
+ lines.push(chalk.cyan('Fills'));
9015
+ for (const f of spec.fills) {
9016
+ if (f.type === 'SOLID') {
9017
+ lines.push(` ${f.hex} ${f.rgba}${f.opacity < 1 ? ` opacity=${f.opacity}` : ''}`);
9018
+ } else if (f.stops) {
9019
+ lines.push(` ${f.type}: ${f.stops.map(s => `${s.color} @${s.position}`).join(' → ')}`);
9020
+ } else {
9021
+ lines.push(` ${f.type}`);
9022
+ }
9023
+ }
9024
+ }
9025
+
9026
+ // Strokes
9027
+ if (spec.strokes && spec.strokes.length > 0) {
9028
+ lines.push(chalk.cyan('Strokes'));
9029
+ for (const s of spec.strokes) {
9030
+ let line = ` ${s.type === 'SOLID' ? s.hex : s.type}`;
9031
+ lines.push(line);
9032
+ }
9033
+ if (spec.strokeWeight) lines.push(` Weight: ${dual(spec.strokeWeight)} Align: ${spec.strokeAlign || 'inside'}`);
9034
+ }
9035
+
9036
+ // Typography
9037
+ if (spec.typography) {
9038
+ const t = spec.typography;
9039
+ lines.push(chalk.cyan('Typography'));
9040
+ if (t.fontFamily) lines.push(` Font: ${t.fontFamily} ${t.fontStyle || ''}`);
9041
+ if (t.fontSize) lines.push(` Size: ${dual(t.fontSize)}`);
9042
+ if (t.fontWeight) lines.push(` Weight: ${t.fontWeight}`);
9043
+ if (t.lineHeight) lines.push(` Line height: ${typeof t.lineHeight === 'string' ? t.lineHeight : dual(t.lineHeight)}`);
9044
+ if (t.letterSpacing) lines.push(` Letter spacing: ${dual(t.letterSpacing)}`);
9045
+ if (t.textAlign) lines.push(` Text align: ${t.textAlign}`);
9046
+ if (t.textDecoration) lines.push(` Decoration: ${t.textDecoration}`);
9047
+ if (t.textTransform) lines.push(` Transform: ${t.textTransform}`);
9048
+ if (t.content) lines.push(` Content: "${t.content.slice(0, 80)}${t.content.length > 80 ? '...' : ''}"`);
9049
+ }
9050
+
9051
+ // Effects
9052
+ if (spec.effects && spec.effects.length > 0) {
9053
+ lines.push(chalk.cyan('Effects'));
9054
+ for (const e of spec.effects) {
9055
+ if (e.type === 'DROP_SHADOW' || e.type === 'INNER_SHADOW') {
9056
+ lines.push(` ${e.type === 'DROP_SHADOW' ? 'Drop shadow' : 'Inner shadow'}: ${px(e.offset.x)}px ${px(e.offset.y)}px ${px(e.blur)}px ${px(e.spread)}px ${e.color || ''}`);
9057
+ lines.push(` → ${rem(e.offset.x)}rem ${rem(e.offset.y)}rem ${rem(e.blur)}rem ${rem(e.spread)}rem`);
9058
+ } else {
9059
+ lines.push(` ${e.type}: blur ${dual(e.blur)}`);
9060
+ }
9061
+ }
9062
+ }
9063
+
9064
+ // Opacity
9065
+ if (spec.opacity !== undefined) {
9066
+ lines.push(chalk.cyan('Opacity') + ` ${spec.opacity}`);
9067
+ }
9068
+
9069
+ // Variable bindings
9070
+ if (spec.variableBindings) {
9071
+ lines.push(chalk.cyan('Variable Bindings'));
9072
+ for (const [prop, binding] of Object.entries(spec.variableBindings)) {
9073
+ if (typeof binding === 'object' && binding.name) {
9074
+ let line = ` ${prop}: ${binding.name}`;
9075
+ if (binding.value !== undefined) line += ` = ${binding.value}`;
9076
+ if (binding.modes) {
9077
+ const modeStr = Object.entries(binding.modes).map(([m, v]) => `${m}: ${v}`).join(', ');
9078
+ line += ` [${modeStr}]`;
9079
+ }
9080
+ lines.push(line);
9081
+ } else {
9082
+ lines.push(` ${prop}: ${binding}`);
9083
+ }
9084
+ }
9085
+ }
9086
+
9087
+ // Component
9088
+ if (spec.component) {
9089
+ lines.push(chalk.cyan('Component'));
9090
+ lines.push(` Name: ${spec.component.name}`);
9091
+ if (spec.component.set) lines.push(` Set: ${spec.component.set}`);
9092
+ if (spec.component.isMainComponent) lines.push(` (Main component definition)`);
9093
+ }
9094
+
9095
+ // Children
9096
+ if (spec.childCount) {
9097
+ lines.push(chalk.cyan(`Children (${spec.childCount})`));
9098
+ for (const c of spec.children) {
9099
+ let line = ` [${c.type}] ${c.name}`;
9100
+ if (c.w && c.h) line += ` ${c.w}x${c.h}`;
9101
+ lines.push(line);
9102
+ }
9103
+ }
9104
+
9105
+ return lines.join('\n');
9106
+ }
9107
+
9108
+ // ============ CSS — generate CSS from selection ============
9109
+
9110
+ const CSS_GEN_CODE = `(function() {
9111
+ var sel = figma.currentPage.selection;
9112
+ if (!sel || sel.length === 0) return { error: 'Nothing selected. Select an element in Figma first.' };
9113
+
9114
+ var BASE = 16;
9115
+ function rem(px) { return parseFloat((px / BASE).toFixed(3)); }
9116
+ function colorHex(c) {
9117
+ var r = Math.round((c.r||0)*255), g = Math.round((c.g||0)*255), b = Math.round((c.b||0)*255);
9118
+ return '#' + r.toString(16).padStart(2,'0') + g.toString(16).padStart(2,'0') + b.toString(16).padStart(2,'0');
9119
+ }
9120
+ function colorRgba(c, a) {
9121
+ var r = Math.round((c.r||0)*255), g = Math.round((c.g||0)*255), b = Math.round((c.b||0)*255);
9122
+ if (a !== undefined && a < 1) return 'rgba(' + r + ',' + g + ',' + b + ',' + parseFloat(a.toFixed(2)) + ')';
9123
+ return 'rgb(' + r + ',' + g + ',' + b + ')';
9124
+ }
9125
+
9126
+ function genCSS(node, unit) {
9127
+ var props = {};
9128
+ var tw = [];
9129
+
9130
+ // Dimensions
9131
+ if (node.width !== undefined) {
9132
+ props['width'] = unit === 'rem' ? rem(node.width) + 'rem' : Math.round(node.width) + 'px';
9133
+ props['height'] = unit === 'rem' ? rem(node.height) + 'rem' : Math.round(node.height) + 'px';
9134
+ }
9135
+
9136
+ // Layout
9137
+ if (node.layoutMode && node.layoutMode !== 'NONE') {
9138
+ props['display'] = 'flex';
9139
+ props['flex-direction'] = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column';
9140
+ if (node.itemSpacing) props['gap'] = unit === 'rem' ? rem(node.itemSpacing) + 'rem' : node.itemSpacing + 'px';
9141
+
9142
+ var pt = node.paddingTop || 0, pr = node.paddingRight || 0, pb = node.paddingBottom || 0, pl = node.paddingLeft || 0;
9143
+ if (pt || pr || pb || pl) {
9144
+ if (pt === pr && pr === pb && pb === pl) {
9145
+ props['padding'] = unit === 'rem' ? rem(pt) + 'rem' : pt + 'px';
9146
+ } else if (pt === pb && pl === pr) {
9147
+ props['padding'] = (unit === 'rem' ? rem(pt) + 'rem ' + rem(pr) + 'rem' : pt + 'px ' + pr + 'px');
9148
+ } else {
9149
+ props['padding'] = unit === 'rem'
9150
+ ? rem(pt) + 'rem ' + rem(pr) + 'rem ' + rem(pb) + 'rem ' + rem(pl) + 'rem'
9151
+ : pt + 'px ' + pr + 'px ' + pb + 'px ' + pl + 'px';
9152
+ }
9153
+ }
9154
+
9155
+ var mainMap = { MIN: 'flex-start', CENTER: 'center', MAX: 'flex-end', SPACE_BETWEEN: 'space-between' };
9156
+ var crossMap = { MIN: 'flex-start', CENTER: 'center', MAX: 'flex-end' };
9157
+ if (node.primaryAxisAlignItems && node.primaryAxisAlignItems !== 'MIN') {
9158
+ props['justify-content'] = mainMap[node.primaryAxisAlignItems] || node.primaryAxisAlignItems;
9159
+ }
9160
+ if (node.counterAxisAlignItems && node.counterAxisAlignItems !== 'MIN') {
9161
+ props['align-items'] = crossMap[node.counterAxisAlignItems] || node.counterAxisAlignItems;
9162
+ }
9163
+ if (node.layoutWrap === 'WRAP') props['flex-wrap'] = 'wrap';
9164
+ }
9165
+
9166
+ // Border radius
9167
+ if (node.cornerRadius && node.cornerRadius !== 0 && typeof node.cornerRadius === 'number') {
9168
+ if (node.cornerRadius >= 9999) {
9169
+ props['border-radius'] = '9999px';
9170
+ } else {
9171
+ props['border-radius'] = unit === 'rem' ? rem(node.cornerRadius) + 'rem' : node.cornerRadius + 'px';
9172
+ }
9173
+ }
9174
+ if (node.topLeftRadius || node.topRightRadius || node.bottomRightRadius || node.bottomLeftRadius) {
9175
+ var tl = node.topLeftRadius||0, tr = node.topRightRadius||0, br = node.bottomRightRadius||0, bl = node.bottomLeftRadius||0;
9176
+ if (tl === tr && tr === br && br === bl) {
9177
+ props['border-radius'] = unit === 'rem' ? rem(tl) + 'rem' : tl + 'px';
9178
+ } else {
9179
+ props['border-radius'] = unit === 'rem'
9180
+ ? rem(tl) + 'rem ' + rem(tr) + 'rem ' + rem(br) + 'rem ' + rem(bl) + 'rem'
9181
+ : tl + 'px ' + tr + 'px ' + br + 'px ' + bl + 'px';
9182
+ }
9183
+ }
9184
+
9185
+ // Fills
9186
+ if (node.fills && node.fills.length > 0) {
9187
+ for (var i = 0; i < node.fills.length; i++) {
9188
+ var f = node.fills[i];
9189
+ if (f.visible === false) continue;
9190
+ if (f.type === 'SOLID' && f.color) {
9191
+ var opacity = f.opacity !== undefined ? f.opacity : 1;
9192
+ props['background-color'] = opacity < 1 ? colorRgba(f.color, opacity) : colorHex(f.color);
9193
+ break;
9194
+ }
9195
+ if (f.type === 'GRADIENT_LINEAR' && f.gradientStops) {
9196
+ var stops = f.gradientStops.map(function(s) { return colorHex(s.color) + ' ' + Math.round(s.position*100) + '%'; });
9197
+ props['background'] = 'linear-gradient(' + stops.join(', ') + ')';
9198
+ break;
9199
+ }
9200
+ }
9201
+ }
9202
+
9203
+ // Strokes
9204
+ if (node.strokes && node.strokes.length > 0) {
9205
+ for (var i = 0; i < node.strokes.length; i++) {
9206
+ var s = node.strokes[i];
9207
+ if (s.visible === false) continue;
9208
+ if (s.type === 'SOLID' && s.color) {
9209
+ var sw = node.strokeWeight || 1;
9210
+ props['border'] = (unit === 'rem' ? rem(sw) + 'rem' : sw + 'px') + ' solid ' + colorHex(s.color);
9211
+ break;
9212
+ }
9213
+ }
9214
+ }
9215
+
9216
+ // Typography
9217
+ if (node.type === 'TEXT') {
9218
+ if (node.fontName && node.fontName !== figma.mixed) {
9219
+ props['font-family'] = "'" + node.fontName.family + "', sans-serif";
9220
+ }
9221
+ if (node.fontSize && node.fontSize !== figma.mixed) {
9222
+ props['font-size'] = unit === 'rem' ? rem(node.fontSize) + 'rem' : node.fontSize + 'px';
9223
+ }
9224
+ if (node.fontName && node.fontName !== figma.mixed && node.fontName.style) {
9225
+ var style = node.fontName.style.toLowerCase();
9226
+ if (style.includes('bold') || style.includes('700')) props['font-weight'] = 'bold';
9227
+ else if (style.includes('semibold') || style.includes('600')) props['font-weight'] = '600';
9228
+ else if (style.includes('medium') || style.includes('500')) props['font-weight'] = '500';
9229
+ else if (style.includes('light') || style.includes('300')) props['font-weight'] = '300';
9230
+ if (style.includes('italic')) props['font-style'] = 'italic';
9231
+ }
9232
+ if (node.lineHeight && node.lineHeight !== figma.mixed) {
9233
+ if (node.lineHeight.unit === 'PIXELS') {
9234
+ props['line-height'] = unit === 'rem' ? rem(node.lineHeight.value) + 'rem' : node.lineHeight.value + 'px';
9235
+ } else if (node.lineHeight.unit === 'PERCENT') {
9236
+ props['line-height'] = (node.lineHeight.value / 100).toFixed(2);
9237
+ }
9238
+ }
9239
+ if (node.letterSpacing && node.letterSpacing !== figma.mixed) {
9240
+ if (node.letterSpacing.unit === 'PIXELS' && node.letterSpacing.value !== 0) {
9241
+ props['letter-spacing'] = unit === 'rem' ? rem(node.letterSpacing.value) + 'rem' : node.letterSpacing.value + 'px';
9242
+ } else if (node.letterSpacing.unit === 'PERCENT' && node.letterSpacing.value !== 0) {
9243
+ props['letter-spacing'] = (node.letterSpacing.value / 100).toFixed(3) + 'em';
9244
+ }
9245
+ }
9246
+ if (node.textAlignHorizontal) {
9247
+ var alignMap = { LEFT: 'left', CENTER: 'center', RIGHT: 'right', JUSTIFIED: 'justify' };
9248
+ if (alignMap[node.textAlignHorizontal] && node.textAlignHorizontal !== 'LEFT') {
9249
+ props['text-align'] = alignMap[node.textAlignHorizontal];
9250
+ }
9251
+ }
9252
+ // Text fill color
9253
+ if (node.fills && node.fills.length > 0) {
9254
+ var tf = node.fills[0];
9255
+ if (tf.type === 'SOLID' && tf.color && tf.visible !== false) {
9256
+ props['color'] = colorHex(tf.color);
9257
+ delete props['background-color'];
9258
+ }
9259
+ }
9260
+ }
9261
+
9262
+ // Effects
9263
+ if (node.effects && node.effects.length > 0) {
9264
+ var shadows = [];
9265
+ for (var i = 0; i < node.effects.length; i++) {
9266
+ var e = node.effects[i];
9267
+ if (e.visible === false) continue;
9268
+ if (e.type === 'DROP_SHADOW') {
9269
+ var ox = unit === 'rem' ? rem(e.offset.x) + 'rem' : e.offset.x + 'px';
9270
+ var oy = unit === 'rem' ? rem(e.offset.y) + 'rem' : e.offset.y + 'px';
9271
+ var bl = unit === 'rem' ? rem(e.radius) + 'rem' : e.radius + 'px';
9272
+ var sp = unit === 'rem' ? rem(e.spread||0) + 'rem' : (e.spread||0) + 'px';
9273
+ shadows.push(ox + ' ' + oy + ' ' + bl + ' ' + sp + ' ' + (e.color ? colorRgba(e.color, e.color.a) : ''));
9274
+ }
9275
+ if (e.type === 'INNER_SHADOW') {
9276
+ var ox = unit === 'rem' ? rem(e.offset.x) + 'rem' : e.offset.x + 'px';
9277
+ var oy = unit === 'rem' ? rem(e.offset.y) + 'rem' : e.offset.y + 'px';
9278
+ var bl = unit === 'rem' ? rem(e.radius) + 'rem' : e.radius + 'px';
9279
+ var sp = unit === 'rem' ? rem(e.spread||0) + 'rem' : (e.spread||0) + 'px';
9280
+ shadows.push('inset ' + ox + ' ' + oy + ' ' + bl + ' ' + sp + ' ' + (e.color ? colorRgba(e.color, e.color.a) : ''));
9281
+ }
9282
+ }
9283
+ if (shadows.length > 0) props['box-shadow'] = shadows.join(', ');
9284
+ }
9285
+
9286
+ // Opacity
9287
+ if (node.opacity !== undefined && node.opacity < 1) {
9288
+ props['opacity'] = parseFloat(node.opacity.toFixed(2));
9289
+ }
9290
+
9291
+ // Overflow
9292
+ if (node.clipsContent) props['overflow'] = 'hidden';
9293
+
9294
+ return { name: node.name, type: node.type, css: props, text: node.type === 'TEXT' ? node.characters : undefined };
9295
+ }
9296
+
9297
+ var results = [];
9298
+ var count = Math.min(sel.length, 10);
9299
+ for (var i = 0; i < count; i++) {
9300
+ results.push(genCSS(sel[i], 'rem'));
9301
+ }
9302
+ return { count: sel.length, nodes: results };
9303
+ })()`;
9304
+
9305
+ program
9306
+ .command('css')
9307
+ .description('Generate CSS from the selected element — ready to paste into your codebase')
9308
+ .option('--px', 'Use px units instead of rem (default: rem)')
9309
+ .option('--tailwind', 'Output Tailwind classes instead of CSS')
9310
+ .option('--json', 'Output raw JSON')
9311
+ .option('--node <id>', 'Target a specific node by ID')
9312
+ .option('--link <url>', 'Target a node from a Figma selection link')
9313
+ .addHelpText('after', `
9314
+ Examples:
9315
+ fig css CSS for current selection (rem units)
9316
+ fig css --px CSS in px units
9317
+ fig css --tailwind Tailwind classes
9318
+ fig css --link "https://..." CSS from a Figma link
9319
+ `)
9320
+ .action(async (options) => {
9321
+ checkConnection();
9322
+ const spinner = ora('Generating CSS...').start();
9323
+
9324
+ try {
9325
+ let code = CSS_GEN_CODE;
9326
+ const unit = options.px ? 'px' : 'rem';
9327
+ code = code.replace("genCSS(sel[i], 'rem')", `genCSS(sel[i], '${unit}')`);
9328
+
9329
+ if (options.link) {
9330
+ const nodeId = parseNodeIdFromLink(options.link);
9331
+ if (!nodeId) { spinner.fail('Could not find a node ID in that link.'); process.exit(1); }
9332
+ code = code.replace(
9333
+ "var sel = figma.currentPage.selection;\n if (!sel || sel.length === 0) return { error: 'Nothing selected. Select an element in Figma first.' };",
9334
+ `var n = figma.getNodeById('${nodeId}'); if (!n) return { error: 'Node not found.' }; var sel = [n];`
9335
+ );
9336
+ } else if (options.node) {
9337
+ code = code.replace(
9338
+ "var sel = figma.currentPage.selection;\n if (!sel || sel.length === 0) return { error: 'Nothing selected. Select an element in Figma first.' };",
9339
+ `var n = figma.getNodeById('${options.node}'); if (!n) return { error: 'Node not found.' }; var sel = [n];`
9340
+ );
9341
+ }
9342
+
9343
+ const result = await daemonExec('eval', { code });
9344
+
9345
+ if (result.error) { spinner.fail(result.error); process.exit(1); }
9346
+
9347
+ spinner.succeed(`Generated CSS for ${result.nodes.length} element${result.nodes.length > 1 ? 's' : ''}`);
9348
+ console.log('');
9349
+
9350
+ if (options.json) {
9351
+ console.log(JSON.stringify(result, null, 2));
9352
+ return;
9353
+ }
9354
+
9355
+ for (const node of result.nodes) {
9356
+ if (options.tailwind) {
9357
+ console.log(formatTailwind(node));
9358
+ } else {
9359
+ console.log(formatCSS(node));
9360
+ }
9361
+ console.log('');
9362
+ }
9363
+ } catch (e) {
9364
+ spinner.fail(`CSS generation failed: ${e.message}`);
9365
+ process.exit(1);
9366
+ }
9367
+ });
9368
+
9369
+ function formatCSS(node) {
9370
+ const lines = [];
9371
+ const className = node.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
9372
+ lines.push(chalk.gray(`/* ${node.name} [${node.type}] */`));
9373
+ lines.push(`.${className} {`);
9374
+ for (const [prop, val] of Object.entries(node.css)) {
9375
+ lines.push(` ${prop}: ${val};`);
9376
+ }
9377
+ lines.push('}');
9378
+ if (node.text) {
9379
+ lines.push(chalk.gray(`/* Content: "${node.text.slice(0, 60)}${node.text.length > 60 ? '...' : ''}" */`));
9380
+ }
9381
+ return lines.join('\n');
9382
+ }
9383
+
9384
+ function formatTailwind(node) {
9385
+ const css = node.css;
9386
+ const classes = [];
9387
+
9388
+ // Width/height
9389
+ if (css['width']) classes.push(`w-[${css['width']}]`);
9390
+ if (css['height']) classes.push(`h-[${css['height']}]`);
9391
+
9392
+ // Display + flex
9393
+ if (css['display'] === 'flex') {
9394
+ classes.push('flex');
9395
+ if (css['flex-direction'] === 'column') classes.push('flex-col');
9396
+ if (css['flex-wrap'] === 'wrap') classes.push('flex-wrap');
9397
+ }
9398
+
9399
+ // Gap
9400
+ if (css['gap']) classes.push(`gap-[${css['gap']}]`);
9401
+
9402
+ // Padding
9403
+ if (css['padding']) {
9404
+ const parts = css['padding'].split(' ');
9405
+ if (parts.length === 1) classes.push(`p-[${parts[0]}]`);
9406
+ else if (parts.length === 2) { classes.push(`py-[${parts[0]}]`); classes.push(`px-[${parts[1]}]`); }
9407
+ else if (parts.length === 4) { classes.push(`pt-[${parts[0]}]`); classes.push(`pr-[${parts[1]}]`); classes.push(`pb-[${parts[2]}]`); classes.push(`pl-[${parts[3]}]`); }
9408
+ }
9409
+
9410
+ // Alignment
9411
+ if (css['justify-content']) {
9412
+ const map = { 'flex-start': 'justify-start', 'center': 'justify-center', 'flex-end': 'justify-end', 'space-between': 'justify-between' };
9413
+ classes.push(map[css['justify-content']] || `justify-[${css['justify-content']}]`);
9414
+ }
9415
+ if (css['align-items']) {
9416
+ const map = { 'flex-start': 'items-start', 'center': 'items-center', 'flex-end': 'items-end' };
9417
+ classes.push(map[css['align-items']] || `items-[${css['align-items']}]`);
9418
+ }
9419
+
9420
+ // Border radius
9421
+ if (css['border-radius']) {
9422
+ if (css['border-radius'] === '9999px') classes.push('rounded-full');
9423
+ else classes.push(`rounded-[${css['border-radius']}]`);
9424
+ }
9425
+
9426
+ // Background
9427
+ if (css['background-color']) classes.push(`bg-[${css['background-color']}]`);
9428
+
9429
+ // Border
9430
+ if (css['border']) classes.push(`border-[${css['border'].split(' solid ')[0]}]`, `border-[${css['border'].split(' solid ')[1] || ''}]`);
9431
+
9432
+ // Typography
9433
+ if (css['font-size']) classes.push(`text-[${css['font-size']}]`);
9434
+ if (css['font-weight']) {
9435
+ const map = { 'bold': 'font-bold', '600': 'font-semibold', '500': 'font-medium', '300': 'font-light' };
9436
+ classes.push(map[css['font-weight']] || `font-[${css['font-weight']}]`);
9437
+ }
9438
+ if (css['font-style'] === 'italic') classes.push('italic');
9439
+ if (css['line-height']) classes.push(`leading-[${css['line-height']}]`);
9440
+ if (css['letter-spacing']) classes.push(`tracking-[${css['letter-spacing']}]`);
9441
+ if (css['text-align']) {
9442
+ const map = { 'center': 'text-center', 'right': 'text-right', 'justify': 'text-justify' };
9443
+ classes.push(map[css['text-align']] || `text-${css['text-align']}`);
9444
+ }
9445
+ if (css['color']) classes.push(`text-[${css['color']}]`);
9446
+
9447
+ // Shadow
9448
+ if (css['box-shadow']) classes.push(`shadow-[${css['box-shadow'].replace(/\s+/g, '_')}]`);
9449
+
9450
+ // Opacity
9451
+ if (css['opacity']) classes.push(`opacity-[${css['opacity']}]`);
9452
+
9453
+ // Overflow
9454
+ if (css['overflow'] === 'hidden') classes.push('overflow-hidden');
9455
+
9456
+ const lines = [];
9457
+ lines.push(chalk.gray(`{/* ${node.name} */}`));
9458
+ lines.push(`className="${classes.join(' ')}"`);
9459
+ if (node.text) lines.push(chalk.gray(`{/* "${node.text.slice(0, 60)}${node.text.length > 60 ? '...' : ''}" */}`));
9460
+ return lines.join('\n');
9461
+ }
9462
+
9463
+ // ============ MEASURE — spacing between elements ============
9464
+
9465
+ program
9466
+ .command('measure')
9467
+ .description('Measure spacing between two selected elements — select exactly 2 layers in Figma')
9468
+ .option('--json', 'Output raw JSON')
9469
+ .action(async (options) => {
9470
+ checkConnection();
9471
+ const spinner = ora('Measuring spacing...').start();
9472
+
9473
+ try {
9474
+ const code = `(function() {
9475
+ var sel = figma.currentPage.selection;
9476
+ if (!sel || sel.length < 2) return { error: 'Select exactly 2 elements in Figma to measure the spacing between them.' };
9477
+ var a = sel[0], b = sel[1];
9478
+ var BASE = 16;
9479
+ function rem(px) { return parseFloat((px / BASE).toFixed(3)); }
9480
+
9481
+ var ax = a.absoluteTransform ? a.absoluteTransform[0][2] : a.x;
9482
+ var ay = a.absoluteTransform ? a.absoluteTransform[1][2] : a.y;
9483
+ var bx = b.absoluteTransform ? b.absoluteTransform[0][2] : b.x;
9484
+ var by = b.absoluteTransform ? b.absoluteTransform[1][2] : b.y;
9485
+ var aw = a.width, ah = a.height;
9486
+ var bw = b.width, bh = b.height;
9487
+
9488
+ var hGap = 0, vGap = 0;
9489
+ // Horizontal gap
9490
+ if (bx >= ax + aw) hGap = bx - (ax + aw);
9491
+ else if (ax >= bx + bw) hGap = ax - (bx + bw);
9492
+ // Vertical gap
9493
+ if (by >= ay + ah) vGap = by - (ay + ah);
9494
+ else if (ay >= by + bh) vGap = ay - (by + bh);
9495
+
9496
+ return {
9497
+ a: { name: a.name, x: Math.round(ax), y: Math.round(ay), w: Math.round(aw), h: Math.round(ah) },
9498
+ b: { name: b.name, x: Math.round(bx), y: Math.round(by), w: Math.round(bw), h: Math.round(bh) },
9499
+ spacing: {
9500
+ horizontal: { px: Math.round(hGap), rem: rem(hGap) },
9501
+ vertical: { px: Math.round(vGap), rem: rem(vGap) }
9502
+ },
9503
+ centerDistance: {
9504
+ x: { px: Math.round(Math.abs((bx + bw/2) - (ax + aw/2))), rem: rem(Math.abs((bx + bw/2) - (ax + aw/2))) },
9505
+ y: { px: Math.round(Math.abs((by + bh/2) - (ay + ah/2))), rem: rem(Math.abs((by + bh/2) - (ay + ah/2))) }
9506
+ }
9507
+ };
9508
+ })()`;
9509
+
9510
+ const result = await daemonExec('eval', { code });
9511
+ if (result.error) { spinner.fail(result.error); process.exit(1); }
9512
+
9513
+ spinner.succeed(`Measured: "${result.a.name}" ↔ "${result.b.name}"`);
9514
+
9515
+ if (options.json) {
9516
+ console.log(JSON.stringify(result, null, 2));
9517
+ } else {
9518
+ console.log('');
9519
+ console.log(chalk.bold(`${result.a.name}`) + chalk.gray(` (${result.a.w}x${result.a.h})`));
9520
+ console.log(chalk.gray(' ↕'));
9521
+ console.log(chalk.bold(`${result.b.name}`) + chalk.gray(` (${result.b.w}x${result.b.h})`));
9522
+ console.log('');
9523
+ console.log(chalk.cyan('Spacing'));
9524
+ console.log(` Horizontal: ${result.spacing.horizontal.px}px (${result.spacing.horizontal.rem}rem)`);
9525
+ console.log(` Vertical: ${result.spacing.vertical.px}px (${result.spacing.vertical.rem}rem)`);
9526
+ console.log(chalk.cyan('Center-to-center'));
9527
+ console.log(` X: ${result.centerDistance.x.px}px (${result.centerDistance.x.rem}rem)`);
9528
+ console.log(` Y: ${result.centerDistance.y.px}px (${result.centerDistance.y.rem}rem)`);
9529
+ }
9530
+ } catch (e) {
9531
+ spinner.fail(`Measure failed: ${e.message}`);
9532
+ process.exit(1);
9533
+ }
9534
+ });
9535
+
9536
+ // ============ STYLES — extract unique styles from a frame ============
9537
+
9538
+ program
9539
+ .command('styles [frameName]')
9540
+ .description('Extract all unique text styles, colors, and spacing values from a frame — a mini style guide')
9541
+ .option('--json', 'Output raw JSON')
9542
+ .option('--selection', 'Extract styles from current selection instead of a named frame')
9543
+ .option('--link <url>', 'Extract styles from a Figma selection link')
9544
+ .addHelpText('after', `
9545
+ Examples:
9546
+ fig styles "Login Screen" All text styles, colors, spacing in that frame
9547
+ fig styles --selection Styles from the current selection
9548
+ fig styles --json Raw JSON output
9549
+ `)
9550
+ .action(async (frameName, options) => {
9551
+ checkConnection();
9552
+ const spinner = ora('Extracting styles...').start();
9553
+
9554
+ try {
9555
+ let nodeSelector;
9556
+ if (options.selection) {
9557
+ nodeSelector = `var sel = figma.currentPage.selection; if (!sel || sel.length === 0) return { error: 'Nothing selected.' }; var root = sel[0];`;
9558
+ } else if (options.link) {
9559
+ const nodeId = parseNodeIdFromLink(options.link);
9560
+ if (!nodeId) { spinner.fail('Could not find a node ID in that link.'); process.exit(1); }
9561
+ nodeSelector = `var root = figma.getNodeById('${nodeId}'); if (!root) return { error: 'Node not found.' };`;
9562
+ } else if (frameName) {
9563
+ nodeSelector = `var frames = figma.currentPage.children.filter(function(n) { return n.name.toLowerCase().includes('${frameName.toLowerCase()}'); }); if (frames.length === 0) return { error: 'Frame "${frameName}" not found.' }; var root = frames[0];`;
9564
+ } else {
9565
+ nodeSelector = `var sel = figma.currentPage.selection; if (!sel || sel.length === 0) return { error: 'Select an element or specify a frame name.' }; var root = sel[0];`;
9566
+ }
9567
+
9568
+ const code = `(function() {
9569
+ ${nodeSelector}
9570
+ var BASE = 16;
9571
+ function rem(px) { return parseFloat((px / BASE).toFixed(3)); }
9572
+ function colorHex(c) {
9573
+ var r = Math.round((c.r||0)*255), g = Math.round((c.g||0)*255), b = Math.round((c.b||0)*255);
9574
+ return '#' + r.toString(16).padStart(2,'0') + g.toString(16).padStart(2,'0') + b.toString(16).padStart(2,'0');
9575
+ }
9576
+
9577
+ var textStyles = {};
9578
+ var colors = {};
9579
+ var spacingValues = {};
9580
+ var radiusValues = {};
9581
+
9582
+ function walk(node) {
9583
+ // Colors from fills
9584
+ if (node.fills && node.fills.length > 0) {
9585
+ for (var i = 0; i < node.fills.length; i++) {
9586
+ var f = node.fills[i];
9587
+ if (f.visible === false) continue;
9588
+ if (f.type === 'SOLID' && f.color) {
9589
+ var hex = colorHex(f.color);
9590
+ if (!colors[hex]) colors[hex] = { count: 0, usedBy: [] };
9591
+ colors[hex].count++;
9592
+ if (colors[hex].usedBy.length < 3) colors[hex].usedBy.push(node.name);
9593
+ }
9594
+ }
9595
+ }
9596
+
9597
+ // Colors from strokes
9598
+ if (node.strokes && node.strokes.length > 0) {
9599
+ for (var i = 0; i < node.strokes.length; i++) {
9600
+ var s = node.strokes[i];
9601
+ if (s.visible === false) continue;
9602
+ if (s.type === 'SOLID' && s.color) {
9603
+ var hex = colorHex(s.color);
9604
+ if (!colors[hex]) colors[hex] = { count: 0, usedBy: [] };
9605
+ colors[hex].count++;
9606
+ }
9607
+ }
9608
+ }
9609
+
9610
+ // Typography
9611
+ if (node.type === 'TEXT') {
9612
+ var family = (node.fontName && node.fontName !== figma.mixed) ? node.fontName.family : 'mixed';
9613
+ var style = (node.fontName && node.fontName !== figma.mixed) ? node.fontName.style : '';
9614
+ var size = (node.fontSize && node.fontSize !== figma.mixed) ? node.fontSize : 0;
9615
+ var lh = '';
9616
+ if (node.lineHeight && node.lineHeight !== figma.mixed) {
9617
+ if (node.lineHeight.unit === 'PIXELS') lh = node.lineHeight.value + 'px';
9618
+ else if (node.lineHeight.unit === 'PERCENT') lh = node.lineHeight.value + '%';
9619
+ else lh = 'auto';
9620
+ }
9621
+ var key = family + '/' + style + '/' + size;
9622
+ if (!textStyles[key]) {
9623
+ textStyles[key] = { fontFamily: family, fontStyle: style, fontSize: size, lineHeight: lh, count: 0 };
9624
+ }
9625
+ textStyles[key].count++;
9626
+ }
9627
+
9628
+ // Spacing (gap, padding)
9629
+ if (node.layoutMode && node.layoutMode !== 'NONE') {
9630
+ if (node.itemSpacing) {
9631
+ var g = Math.round(node.itemSpacing);
9632
+ spacingValues[g + 'px'] = (spacingValues[g + 'px'] || 0) + 1;
9633
+ }
9634
+ var sides = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft];
9635
+ sides.forEach(function(v) {
9636
+ if (v && v > 0) {
9637
+ var k = Math.round(v) + 'px';
9638
+ spacingValues[k] = (spacingValues[k] || 0) + 1;
9639
+ }
9640
+ });
9641
+ }
9642
+
9643
+ // Radius
9644
+ if (node.cornerRadius && node.cornerRadius !== 0 && typeof node.cornerRadius === 'number') {
9645
+ var rk = node.cornerRadius >= 9999 ? 'full' : Math.round(node.cornerRadius) + 'px';
9646
+ radiusValues[rk] = (radiusValues[rk] || 0) + 1;
9647
+ }
9648
+
9649
+ if (node.children) {
9650
+ for (var i = 0; i < node.children.length; i++) walk(node.children[i]);
9651
+ }
9652
+ }
9653
+ walk(root);
9654
+
9655
+ return {
9656
+ name: root.name,
9657
+ textStyles: Object.values(textStyles).sort(function(a,b) { return b.count - a.count; }),
9658
+ colors: Object.keys(colors).map(function(hex) { return { hex: hex, count: colors[hex].count, usedBy: colors[hex].usedBy }; }).sort(function(a,b) { return b.count - a.count; }),
9659
+ spacing: Object.keys(spacingValues).map(function(k) { var px = parseInt(k); return { value: k, rem: rem(px) + 'rem', count: spacingValues[k] }; }).sort(function(a,b) { return parseInt(a.value) - parseInt(b.value); }),
9660
+ radii: Object.keys(radiusValues).map(function(k) { var px = k === 'full' ? 9999 : parseInt(k); return { value: k, rem: k === 'full' ? 'full' : rem(px) + 'rem', count: radiusValues[k] }; }).sort(function(a,b) { return parseInt(a.value) - parseInt(b.value); })
9661
+ };
9662
+ })()`;
9663
+
9664
+ const result = await daemonExec('eval', { code });
9665
+ if (result.error) { spinner.fail(result.error); process.exit(1); }
9666
+
9667
+ spinner.succeed(`Styles extracted from "${result.name}"`);
9668
+
9669
+ if (options.json) {
9670
+ console.log(JSON.stringify(result, null, 2));
9671
+ return;
9672
+ }
9673
+
9674
+ console.log('');
9675
+ // Text styles
9676
+ if (result.textStyles.length > 0) {
9677
+ console.log(chalk.cyan.bold(`Typography (${result.textStyles.length} styles)`));
9678
+ for (const t of result.textStyles) {
9679
+ let line = ` ${t.fontFamily} ${t.fontStyle} ${t.fontSize}px (${(t.fontSize/16).toFixed(3)}rem)`;
9680
+ if (t.lineHeight) line += ` line-height: ${t.lineHeight}`;
9681
+ line += chalk.gray(` ×${t.count}`);
9682
+ console.log(line);
9683
+ }
9684
+ console.log('');
9685
+ }
9686
+
9687
+ // Colors
9688
+ if (result.colors.length > 0) {
9689
+ console.log(chalk.cyan.bold(`Colors (${result.colors.length} unique)`));
9690
+ for (const c of result.colors) {
9691
+ let line = ` ${c.hex}`;
9692
+ if (c.usedBy.length > 0) line += chalk.gray(` used by: ${c.usedBy.join(', ')}`);
9693
+ line += chalk.gray(` ×${c.count}`);
9694
+ console.log(line);
9695
+ }
9696
+ console.log('');
9697
+ }
9698
+
9699
+ // Spacing scale
9700
+ if (result.spacing.length > 0) {
9701
+ console.log(chalk.cyan.bold(`Spacing scale (${result.spacing.length} values)`));
9702
+ for (const s of result.spacing) {
9703
+ console.log(` ${s.value.padEnd(8)} ${s.rem.padEnd(10)}` + chalk.gray(`×${s.count}`));
9704
+ }
9705
+ console.log('');
9706
+ }
9707
+
9708
+ // Radii
9709
+ if (result.radii.length > 0) {
9710
+ console.log(chalk.cyan.bold(`Border radii (${result.radii.length} values)`));
9711
+ for (const r of result.radii) {
9712
+ console.log(` ${r.value.padEnd(8)} ${r.rem.padEnd(10)}` + chalk.gray(`×${r.count}`));
9713
+ }
9714
+ }
9715
+ } catch (e) {
9716
+ spinner.fail(`Styles extraction failed: ${e.message}`);
9717
+ process.exit(1);
9718
+ }
9719
+ });
9720
+
9721
+ // ============ DOCUMENT — deep component documentation ============
9722
+
9723
+ const DOCUMENT_CODE = `(function() {
9724
+ var BASE = 16;
9725
+ function toRem(px) { return parseFloat((px / BASE).toFixed(3)); }
9726
+ function dual(px) { return { px: Math.round(px), rem: toRem(px) }; }
9727
+ function colorToHex(c) {
9728
+ var r = Math.round((c.r||0)*255), g = Math.round((c.g||0)*255), b = Math.round((c.b||0)*255);
9729
+ return '#' + r.toString(16).padStart(2,'0') + g.toString(16).padStart(2,'0') + b.toString(16).padStart(2,'0');
9730
+ }
9731
+ function colorToRgba(c, opacity) {
9732
+ var r = Math.round((c.r||0)*255), g = Math.round((c.g||0)*255), b = Math.round((c.b||0)*255);
9733
+ if (opacity !== undefined && opacity < 1) return 'rgba(' + r + ',' + g + ',' + b + ',' + parseFloat(opacity.toFixed(2)) + ')';
9734
+ return 'rgb(' + r + ',' + g + ',' + b + ')';
9735
+ }
9736
+
9737
+ function extractNode(node, depth) {
9738
+ if (depth > 15) return null;
9739
+ var n = { name: node.name, type: node.type };
9740
+
9741
+ // Dimensions
9742
+ if (node.width !== undefined) {
9743
+ n.width = dual(node.width);
9744
+ n.height = dual(node.height);
9745
+ }
9746
+
9747
+ // Visibility & opacity
9748
+ if (node.visible === false) n.visible = false;
9749
+ if (node.opacity !== undefined && node.opacity < 1) n.opacity = parseFloat(node.opacity.toFixed(2));
9750
+
9751
+ // Auto layout
9752
+ if (node.layoutMode && node.layoutMode !== 'NONE') {
9753
+ n.layout = {
9754
+ direction: node.layoutMode === 'HORIZONTAL' ? 'row' : 'column',
9755
+ gap: dual(node.itemSpacing || 0),
9756
+ padding: {
9757
+ top: dual(node.paddingTop || 0),
9758
+ right: dual(node.paddingRight || 0),
9759
+ bottom: dual(node.paddingBottom || 0),
9760
+ left: dual(node.paddingLeft || 0)
9761
+ },
9762
+ mainAxisAlign: node.primaryAxisAlignItems || 'MIN',
9763
+ crossAxisAlign: node.counterAxisAlignItems || 'MIN'
9764
+ };
9765
+ if (node.layoutWrap === 'WRAP') n.layout.wrap = true;
9766
+ if (node.primaryAxisSizingMode) n.layout.mainAxisSizing = node.primaryAxisSizingMode;
9767
+ if (node.counterAxisSizingMode) n.layout.crossAxisSizing = node.counterAxisSizingMode;
9768
+ }
9769
+
9770
+ // Sizing behavior
9771
+ if (node.layoutSizingHorizontal) n.horizontalSizing = node.layoutSizingHorizontal;
9772
+ if (node.layoutSizingVertical) n.verticalSizing = node.layoutSizingVertical;
9773
+
9774
+ // Corner radius
9775
+ if (node.cornerRadius !== undefined && node.cornerRadius !== 0 && typeof node.cornerRadius === 'number') {
9776
+ n.borderRadius = dual(node.cornerRadius);
9777
+ } else if (node.topLeftRadius || node.topRightRadius || node.bottomRightRadius || node.bottomLeftRadius) {
9778
+ n.borderRadius = {
9779
+ topLeft: dual(node.topLeftRadius || 0),
9780
+ topRight: dual(node.topRightRadius || 0),
9781
+ bottomRight: dual(node.bottomRightRadius || 0),
9782
+ bottomLeft: dual(node.bottomLeftRadius || 0)
9783
+ };
9784
+ }
9785
+
9786
+ // Fills
9787
+ if (node.fills && node.fills.length > 0) {
9788
+ n.fills = [];
9789
+ for (var i = 0; i < node.fills.length; i++) {
9790
+ var f = node.fills[i];
9791
+ if (f.visible === false) continue;
9792
+ var fill = { type: f.type };
9793
+ if (f.type === 'SOLID' && f.color) {
9794
+ fill.hex = colorToHex(f.color);
9795
+ fill.rgba = colorToRgba(f.color, f.opacity);
9796
+ if (f.opacity !== undefined && f.opacity < 1) fill.opacity = parseFloat(f.opacity.toFixed(2));
9797
+ }
9798
+ if (f.type === 'GRADIENT_LINEAR' || f.type === 'GRADIENT_RADIAL') {
9799
+ fill.stops = f.gradientStops ? f.gradientStops.map(function(s) {
9800
+ return { color: colorToHex(s.color), position: parseFloat(s.position.toFixed(2)) };
9801
+ }) : [];
9802
+ }
9803
+ if (f.type === 'IMAGE') fill.scaleMode = f.scaleMode;
9804
+ n.fills.push(fill);
9805
+ }
9806
+ if (n.fills.length === 0) delete n.fills;
9807
+ }
9808
+
9809
+ // Strokes
9810
+ if (node.strokes && node.strokes.length > 0) {
9811
+ n.strokes = [];
9812
+ for (var i = 0; i < node.strokes.length; i++) {
9813
+ var s = node.strokes[i];
9814
+ if (s.visible === false) continue;
9815
+ var stroke = { type: s.type };
9816
+ if (s.type === 'SOLID' && s.color) {
9817
+ stroke.hex = colorToHex(s.color);
9818
+ stroke.rgba = colorToRgba(s.color, s.opacity);
9819
+ }
9820
+ n.strokes.push(stroke);
9821
+ }
9822
+ if (n.strokes.length === 0) delete n.strokes;
9823
+ if (node.strokeWeight) n.strokeWeight = dual(node.strokeWeight);
9824
+ if (node.strokeAlign) n.strokeAlign = node.strokeAlign;
9825
+ }
9826
+
9827
+ // Typography
9828
+ if (node.type === 'TEXT') {
9829
+ n.text = { content: node.characters };
9830
+ if (node.fontName && node.fontName !== figma.mixed) {
9831
+ n.text.fontFamily = node.fontName.family;
9832
+ n.text.fontStyle = node.fontName.style;
9833
+ }
9834
+ if (node.fontSize && node.fontSize !== figma.mixed) n.text.fontSize = dual(node.fontSize);
9835
+ if (node.fontWeight && node.fontWeight !== figma.mixed) n.text.fontWeight = node.fontWeight;
9836
+ if (node.lineHeight && node.lineHeight !== figma.mixed) {
9837
+ if (node.lineHeight.unit === 'PIXELS') n.text.lineHeight = dual(node.lineHeight.value);
9838
+ else if (node.lineHeight.unit === 'PERCENT') n.text.lineHeight = node.lineHeight.value + '%';
9839
+ else n.text.lineHeight = 'auto';
9840
+ }
9841
+ if (node.letterSpacing && node.letterSpacing !== figma.mixed) {
9842
+ if (node.letterSpacing.unit === 'PIXELS') n.text.letterSpacing = dual(node.letterSpacing.value);
9843
+ else if (node.letterSpacing.unit === 'PERCENT') n.text.letterSpacing = node.letterSpacing.value + '%';
9844
+ }
9845
+ if (node.textAlignHorizontal) n.text.textAlign = node.textAlignHorizontal.toLowerCase();
9846
+ if (node.textDecoration && node.textDecoration !== 'NONE' && node.textDecoration !== figma.mixed) {
9847
+ n.text.textDecoration = node.textDecoration.toLowerCase();
9848
+ }
9849
+ if (node.textCase && node.textCase !== 'ORIGINAL' && node.textCase !== figma.mixed) {
9850
+ n.text.textTransform = node.textCase.toLowerCase();
9851
+ }
9852
+ }
9853
+
9854
+ // Effects
9855
+ if (node.effects && node.effects.length > 0) {
9856
+ n.effects = [];
9857
+ for (var i = 0; i < node.effects.length; i++) {
9858
+ var e = node.effects[i];
9859
+ if (e.visible === false) continue;
9860
+ var eff = { type: e.type };
9861
+ if (e.type === 'DROP_SHADOW' || e.type === 'INNER_SHADOW') {
9862
+ eff.x = dual(e.offset.x); eff.y = dual(e.offset.y);
9863
+ eff.blur = dual(e.radius); eff.spread = dual(e.spread || 0);
9864
+ if (e.color) eff.color = colorToRgba(e.color, e.color.a);
9865
+ }
9866
+ if (e.type === 'LAYER_BLUR' || e.type === 'BACKGROUND_BLUR') {
9867
+ eff.blur = dual(e.radius);
9868
+ }
9869
+ n.effects.push(eff);
9870
+ }
9871
+ if (n.effects.length === 0) delete n.effects;
9872
+ }
9873
+
9874
+ // Constraints
9875
+ if (node.constraints) {
9876
+ n.constraints = { horizontal: node.constraints.horizontal, vertical: node.constraints.vertical };
9877
+ }
9878
+
9879
+ // Clip content
9880
+ if (node.clipsContent) n.clipsContent = true;
9881
+
9882
+ // Component info
9883
+ if (node.type === 'INSTANCE' && node.mainComponent) {
9884
+ n.component = { name: node.mainComponent.name, id: node.mainComponent.id };
9885
+ if (node.mainComponent.parent && node.mainComponent.parent.type === 'COMPONENT_SET') {
9886
+ n.component.set = node.mainComponent.parent.name;
9887
+ }
9888
+ // Overridden properties
9889
+ try {
9890
+ var overrides = node.overrides;
9891
+ if (overrides && overrides.length > 0) {
9892
+ n.component.overrideCount = overrides.length;
9893
+ }
9894
+ } catch(e) {}
9895
+ }
9896
+ if (node.type === 'COMPONENT') {
9897
+ n.component = { name: node.name, id: node.id, isDefinition: true };
9898
+ if (node.description) n.component.description = node.description;
9899
+ }
9900
+
9901
+ // Variable bindings
9902
+ if (node.boundVariables) {
9903
+ var vars = {};
9904
+ var bindings = node.boundVariables;
9905
+ Object.keys(bindings).forEach(function(prop) {
9906
+ var binding = bindings[prop];
9907
+ if (!binding) return;
9908
+ var entries = Array.isArray(binding) ? binding : [binding];
9909
+ entries.forEach(function(b) {
9910
+ if (!b || !b.id) return;
9911
+ try {
9912
+ var v = figma.variables.getVariableById(b.id);
9913
+ if (v) {
9914
+ var info = { name: v.name };
9915
+ try {
9916
+ var modeIds = Object.keys(v.valuesByMode);
9917
+ if (modeIds.length > 0) {
9918
+ var val = v.valuesByMode[modeIds[0]];
9919
+ if (val !== undefined) {
9920
+ if (typeof val === 'object' && val.r !== undefined) {
9921
+ info.value = colorToHex(val);
9922
+ } else if (typeof val === 'object' && val.id) {
9923
+ try {
9924
+ var alias = figma.variables.getVariableById(val.id);
9925
+ if (alias) info.value = alias.name + ' (alias)';
9926
+ } catch(e2) { info.value = 'alias'; }
9927
+ } else {
9928
+ info.value = val;
9929
+ }
9930
+ }
9931
+ }
9932
+ if (modeIds.length > 1) {
9933
+ info.modes = {};
9934
+ for (var mi = 0; mi < modeIds.length; mi++) {
9935
+ var mVal = v.valuesByMode[modeIds[mi]];
9936
+ try {
9937
+ var collection = figma.variables.getVariableCollectionById(v.variableCollectionId);
9938
+ var modeName = collection ? collection.modes.find(function(m) { return m.modeId === modeIds[mi]; }) : null;
9939
+ var mKey = modeName ? modeName.name : modeIds[mi];
9940
+ if (typeof mVal === 'object' && mVal.r !== undefined) {
9941
+ info.modes[mKey] = colorToHex(mVal);
9942
+ } else if (typeof mVal === 'object' && mVal.id) {
9943
+ try {
9944
+ var aliasM = figma.variables.getVariableById(mVal.id);
9945
+ info.modes[mKey] = aliasM ? aliasM.name : 'alias';
9946
+ } catch(e3) { info.modes[mKey] = 'alias'; }
9947
+ } else {
9948
+ info.modes[mKey] = mVal;
9949
+ }
9950
+ } catch(e4) {}
9951
+ }
9952
+ }
9953
+ } catch(e5) {}
9954
+ vars[prop] = info;
9955
+ }
9956
+ } catch(e) {}
9957
+ });
9958
+ });
9959
+ if (Object.keys(vars).length > 0) n.variables = vars;
9960
+ }
9961
+
9962
+ // Children — recurse
9963
+ if (node.children && node.children.length > 0) {
9964
+ n.children = [];
9965
+ for (var i = 0; i < node.children.length; i++) {
9966
+ var child = extractNode(node.children[i], depth + 1);
9967
+ if (child) n.children.push(child);
9968
+ }
9969
+ }
9970
+
9971
+ return n;
9972
+ }
9973
+
9974
+ // Collect unique tokens used across the tree
9975
+ function collectTokens(tree) {
9976
+ var colors = {};
9977
+ var fonts = {};
9978
+ var spacings = {};
9979
+ var radii = {};
9980
+ var shadows = {};
9981
+
9982
+ function walk(n) {
9983
+ // Colors from fills
9984
+ if (n.fills) n.fills.forEach(function(f) {
9985
+ if (f.hex) colors[f.hex] = (colors[f.hex] || 0) + 1;
9986
+ });
9987
+ // Colors from strokes
9988
+ if (n.strokes) n.strokes.forEach(function(s) {
9989
+ if (s.hex) colors[s.hex] = (colors[s.hex] || 0) + 1;
9990
+ });
9991
+ // Typography
9992
+ if (n.text) {
9993
+ if (n.text.fontFamily) {
9994
+ var key = n.text.fontFamily + ' ' + (n.text.fontStyle || 'Regular');
9995
+ if (n.text.fontSize) key += ' / ' + n.text.fontSize.px + 'px';
9996
+ fonts[key] = (fonts[key] || 0) + 1;
9997
+ }
9998
+ }
9999
+ // Spacing from layout
10000
+ if (n.layout) {
10001
+ if (n.layout.gap && n.layout.gap.px > 0) spacings[n.layout.gap.px + 'px'] = (spacings[n.layout.gap.px + 'px'] || 0) + 1;
10002
+ if (n.layout.padding) {
10003
+ var p = n.layout.padding;
10004
+ [p.top, p.right, p.bottom, p.left].forEach(function(v) {
10005
+ if (v && v.px > 0) spacings[v.px + 'px'] = (spacings[v.px + 'px'] || 0) + 1;
10006
+ });
10007
+ }
10008
+ }
10009
+ // Radii
10010
+ if (n.borderRadius) {
10011
+ if (n.borderRadius.px !== undefined) {
10012
+ radii[n.borderRadius.px + 'px'] = (radii[n.borderRadius.px + 'px'] || 0) + 1;
10013
+ } else {
10014
+ [n.borderRadius.topLeft, n.borderRadius.topRight, n.borderRadius.bottomRight, n.borderRadius.bottomLeft].forEach(function(v) {
10015
+ if (v && v.px > 0) radii[v.px + 'px'] = (radii[v.px + 'px'] || 0) + 1;
10016
+ });
10017
+ }
10018
+ }
10019
+ // Shadows
10020
+ if (n.effects) n.effects.forEach(function(e) {
10021
+ if (e.type === 'DROP_SHADOW' || e.type === 'INNER_SHADOW') {
10022
+ var key = e.x.px + 'px ' + e.y.px + 'px ' + e.blur.px + 'px ' + e.spread.px + 'px ' + (e.color || '');
10023
+ shadows[key] = (shadows[key] || 0) + 1;
10024
+ }
10025
+ });
10026
+ // Recurse
10027
+ if (n.children) n.children.forEach(walk);
10028
+ }
10029
+ walk(tree);
10030
+
10031
+ return {
10032
+ colors: Object.keys(colors).sort(function(a,b) { return colors[b] - colors[a]; }).map(function(c) { return { value: c, count: colors[c] }; }),
10033
+ typography: Object.keys(fonts).sort(function(a,b) { return fonts[b] - fonts[a]; }).map(function(f) { return { value: f, count: fonts[f] }; }),
10034
+ spacing: Object.keys(spacings).sort(function(a,b) { return parseInt(a) - parseInt(b); }).map(function(s) { return { value: s, rem: toRem(parseInt(s)) + 'rem', count: spacings[s] }; }),
10035
+ radii: Object.keys(radii).sort(function(a,b) { return parseInt(a) - parseInt(b); }).map(function(r) { return { value: r, rem: toRem(parseInt(r)) + 'rem', count: radii[r] }; }),
10036
+ shadows: Object.keys(shadows).map(function(s) { return { value: s, count: shadows[s] }; })
10037
+ };
10038
+ }
10039
+
10040
+ // Count total nodes
10041
+ function countNodes(n) {
10042
+ var c = 1;
10043
+ if (n.children) n.children.forEach(function(ch) { c += countNodes(ch); });
10044
+ return c;
10045
+ }
10046
+
10047
+ __TARGET_SELECTOR__
10048
+
10049
+ var tree = extractNode(node, 0);
10050
+ var tokens = collectTokens(tree);
10051
+ var total = countNodes(tree);
10052
+
10053
+ return {
10054
+ summary: {
10055
+ name: node.name,
10056
+ type: node.type,
10057
+ totalNodes: total,
10058
+ width: node.width ? Math.round(node.width) : undefined,
10059
+ height: node.height ? Math.round(node.height) : undefined,
10060
+ isComponent: node.type === 'COMPONENT' || node.type === 'COMPONENT_SET',
10061
+ isInstance: node.type === 'INSTANCE'
10062
+ },
10063
+ tokens: tokens,
10064
+ tree: tree
10065
+ };
10066
+ })()`;
10067
+
10068
+ function buildDocumentCode(target) {
10069
+ if (target === 'selection') {
10070
+ return DOCUMENT_CODE.replace('__TARGET_SELECTOR__',
10071
+ "var sel = figma.currentPage.selection;\n if (!sel || sel.length === 0) return { error: 'Nothing selected. Select a component or frame in Figma first.' };\n var node = sel[0];"
10072
+ );
10073
+ }
10074
+ // target is a node ID
10075
+ return DOCUMENT_CODE.replace('__TARGET_SELECTOR__',
10076
+ `var node = figma.getNodeById('${target}');\n if (!node) return { error: 'Node ${target} not found.' };`
10077
+ );
10078
+ }
10079
+
10080
+ function formatDocument(doc) {
10081
+ const lines = [];
10082
+
10083
+ // Header
10084
+ lines.push(chalk.bold.underline(`Component Documentation: ${doc.summary.name}`));
10085
+ lines.push('');
10086
+
10087
+ // Summary
10088
+ lines.push(chalk.bold('Overview'));
10089
+ lines.push(` Type: ${doc.summary.type}`);
10090
+ lines.push(` Size: ${doc.summary.width}×${doc.summary.height}px`);
10091
+ lines.push(` Total nodes: ${doc.summary.totalNodes}`);
10092
+ if (doc.summary.isComponent) lines.push(` Role: Component definition`);
10093
+ if (doc.summary.isInstance) lines.push(` Role: Component instance`);
10094
+ lines.push('');
10095
+
10096
+ // Design tokens summary
10097
+ lines.push(chalk.bold('Design Tokens'));
10098
+ lines.push('');
10099
+
10100
+ if (doc.tokens.colors.length > 0) {
10101
+ lines.push(chalk.cyan(' Colors'));
10102
+ for (const c of doc.tokens.colors) {
10103
+ lines.push(` ${c.value} (used ${c.count}×)`);
10104
+ }
10105
+ }
10106
+
10107
+ if (doc.tokens.typography.length > 0) {
10108
+ lines.push(chalk.cyan(' Typography'));
10109
+ for (const t of doc.tokens.typography) {
10110
+ lines.push(` ${t.value} (${t.count}×)`);
10111
+ }
10112
+ }
10113
+
10114
+ if (doc.tokens.spacing.length > 0) {
10115
+ lines.push(chalk.cyan(' Spacing'));
10116
+ lines.push(` ${doc.tokens.spacing.map(s => `${s.value} (${s.rem})`).join(' | ')}`);
10117
+ }
10118
+
10119
+ if (doc.tokens.radii.length > 0) {
10120
+ lines.push(chalk.cyan(' Border Radii'));
10121
+ lines.push(` ${doc.tokens.radii.map(r => `${r.value} (${r.rem})`).join(' | ')}`);
10122
+ }
10123
+
10124
+ if (doc.tokens.shadows.length > 0) {
10125
+ lines.push(chalk.cyan(' Shadows'));
10126
+ for (const s of doc.tokens.shadows) {
10127
+ lines.push(` ${s.value}`);
10128
+ }
10129
+ }
10130
+
10131
+ lines.push('');
10132
+ lines.push(chalk.bold('Component Tree (JSON)'));
10133
+ lines.push('');
10134
+ lines.push(JSON.stringify(doc.tree, null, 2));
10135
+
10136
+ return lines.join('\n');
10137
+ }
10138
+
10139
+ program
10140
+ .command('document')
10141
+ .description('Generate complete component documentation — recursive specs, tokens, and structured tree for coding agents')
10142
+ .option('--json', 'Output raw JSON only (best for coding agents)')
10143
+ .option('--node <id>', 'Document a specific node by ID')
10144
+ .option('--link <url>', 'Document from a Figma selection link')
10145
+ .option('--selection', 'Document the current selection (default)')
10146
+ .option('--tokens-only', 'Output only the design tokens summary')
10147
+ .addHelpText('after', `
10148
+ Examples:
10149
+ fig document Document the selected component
10150
+ fig document --json JSON output for coding agents
10151
+ fig document --node "123:456" Document a specific node
10152
+ fig document --link "https://..." Document from a Figma link
10153
+ fig document --tokens-only Just the design tokens used
10154
+ `)
10155
+ .action(async (options) => {
10156
+ checkConnection();
10157
+ const spinner = ora('Documenting component...').start();
10158
+
10159
+ try {
10160
+ let target = 'selection';
10161
+
10162
+ if (options.link) {
10163
+ const nodeId = parseNodeIdFromLink(options.link);
10164
+ if (!nodeId) {
10165
+ spinner.fail('Could not find a node ID in that link. Copy via right-click → "Copy link to selection" in Figma.');
10166
+ process.exit(1);
10167
+ }
10168
+ target = nodeId;
10169
+ } else if (options.node) {
10170
+ target = options.node;
10171
+ }
10172
+
10173
+ const code = buildDocumentCode(target);
10174
+ const result = await daemonExec('eval', { code }, 120000);
10175
+
10176
+ if (result.error) {
10177
+ spinner.fail(result.error);
10178
+ process.exit(1);
10179
+ }
10180
+
10181
+ spinner.succeed(`Documented "${result.summary.name}" — ${result.summary.totalNodes} nodes`);
10182
+
10183
+ if (options.tokensOnly) {
10184
+ console.log(JSON.stringify(result.tokens, null, 2));
10185
+ } else if (options.json) {
10186
+ console.log(JSON.stringify(result, null, 2));
10187
+ } else {
10188
+ console.log('');
10189
+ console.log(formatDocument(result));
10190
+ }
10191
+ } catch (e) {
10192
+ spinner.fail(`Documentation failed: ${e.message}`);
10193
+ process.exit(1);
10194
+ }
10195
+ });
10196
+
8450
10197
  // ============ PROMPT — export to AI tool ============
8451
10198
 
8452
10199
  program
8453
10200
  .command('prompt [frameName]')
8454
- .description('Generate a lean, tool-specific AI prompt from a Figma frame')
10201
+ .description('Export a frame as a text prompt for AI tools (Figma Make, Lovable, Pencil, Stitch)')
8455
10202
  .option('-t, --target <tool>', 'Target tool: figma-make | lovable | pencil | paper | stitch', 'figma-make')
8456
10203
  .option('-p, --platform <platform>', 'desktop | mobile | responsive', 'desktop')
8457
10204
  .option('-s, --stack <stack>', 'Tech stack (for Lovable/Pencil)', 'React + shadcn/ui + Tailwind')