figma-rust-mcp 2.9.5 β†’ 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,7 +147,7 @@ Add to your `~/.gemini/config/mcp_config.json` or project `.agents/mcp_config.js
147
147
 
148
148
  ## πŸ› οΈ MCP Tools Reference
149
149
 
150
- `figma-mcp` provides **12 first-class MCP tools**:
150
+ `figma-mcp` provides **14 first-class MCP tools**:
151
151
 
152
152
  ### 1. `figma_status`
153
153
  Checks live bridge connection status, connected Figma tabs/files, in-memory index health, queue length, and latency statistics.
@@ -217,6 +217,19 @@ Audits the current Figma document and generates a complete design system rule sh
217
217
  Fetches built-in documentation, design rules, layout guidelines, and code examples for `figma_write`.
218
218
  - **`section`**: `"rules"` | `"layout"` | `"api"` | `"tokens"` | `"icons"`
219
219
 
220
+ ### 13. `figma_verify_ui`
221
+ Validates and compares actual rendered HTML/React UI styles and layout against Figma design specifications.
222
+ - Checks dimensions, padding, flex gap, colors, border-radius, and typography.
223
+ - Returns an exact match percentage (`match_percentage`), discrepancy breakdown, and actionable CSS/Tailwind fixes.
224
+ - **`nodeId`** / **`nodeName`**: Target Figma node spec.
225
+ - **`computedStyles`**: Key-value pairs of computed CSS properties from browser/component inspection.
226
+ - **`url`** / **`selector`**: Contextual target URL or CSS selector inspected.
227
+
228
+ ### 14. `figma_match_components`
229
+ Scans your local codebase directories (`src/components`, `components/ui`) to discover existing React/Vue components and matches them directly against Figma design layers, preventing duplicate component creation and ensuring reuse of existing design systems.
230
+ - **`projectDir`**: Base directory of your project (default: current directory `"."`).
231
+ - **`nodeId`**: Optional Figma node ID to check specific match against local components.
232
+
220
233
  ---
221
234
 
222
235
  ## πŸ’» Development & Testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-rust-mcp",
3
- "version": "2.9.5",
3
+ "version": "3.0.0",
4
4
  "description": "Claude Code to Figma β€” Bidirectional MCP bridge. AI draws UI on Figma canvas, reads designs back. Works with Claude, Cursor, VS Code, Windsurf, Antigravity. No API key needed.",
5
5
  "mcpName": "io.github.BuiHung1612/figma-mcp",
6
6
  "type": "module",
package/plugin/code.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // figma-mcp β€” Figma Plugin main thread v1.2.1
2
2
  // Handles both WRITE (draw UI) and READ (extract design) operations.
3
3
  // AUTO-GENERATED by scripts/build-plugin.js β€” edit plugin-src/*.js instead
4
- // Generated: 2026-08-26T09:42:07.781Z
4
+ // Generated: 2026-08-27T08:12:15.747Z
5
5
 
6
6
  // ── plugin-src/utils.js ──
7
7
  // ─── UTILS ────────────────────────────────────────────────────────────────────
@@ -328,6 +328,13 @@ function nodeToInfo(node) {
328
328
  return info;
329
329
  }
330
330
 
331
+ // Yield execution back to Figma main thread loop so UI never freezes/blocks
332
+ function yieldToUI(delayMs) {
333
+ return new Promise(function(resolve) {
334
+ setTimeout(resolve, delayMs !== undefined ? delayMs : 0);
335
+ });
336
+ }
337
+
331
338
 
332
339
  // ── plugin-src/svg-path-helpers.js ──
333
340
  // ─── SVG PATH NORMALIZATION ───────────────────────────────────────────────────
@@ -1238,7 +1245,7 @@ const handlers = {};
1238
1245
 
1239
1246
  handlers.status = async () => ({
1240
1247
  connected: true,
1241
- version: "2.8.5",
1248
+ version: "3.0.0",
1242
1249
  fileName: figma.root.name,
1243
1250
  currentPage: figma.currentPage.name,
1244
1251
  pageCount: figma.root.children.length,
@@ -3035,60 +3042,98 @@ handlers.export_image = async function(params) {
3035
3042
  };
3036
3043
  };
3037
3044
 
3038
- // index_scan β€” aggregate page nodes, styles, variables, and components in one call for pre-indexing (concurrently)
3045
+ // index_scan β€” aggregate page nodes, styles, variables, and components in one call for pre-indexing
3039
3046
  handlers.index_scan = async function() {
3040
- var pageNodesPromise = (async function() {
3047
+ if (figma.ui) {
3041
3048
  try {
3042
- var page = figma.currentPage;
3043
- var topFrames = page.children || [];
3044
- var collected = [];
3045
- var CHUNK_SIZE = 10;
3046
-
3047
- for (var i = 0; i < topFrames.length; i += CHUNK_SIZE) {
3048
- var chunk = topFrames.slice(i, i + CHUNK_SIZE);
3049
- var chunkData = chunk.map(function(n) {
3050
- return Object.assign(nodeToInfo(n), { childCount: "children" in n ? n.children.length : 0 });
3051
- });
3052
- collected = collected.concat(chunkData);
3049
+ figma.ui.postMessage({ type: "index-progress", stage: "nodes", percent: 5, label: "Scanning canvas layers…" });
3050
+ } catch(e) {}
3051
+ }
3053
3052
 
3054
- // Stream chunk progressively if there are multiple chunks
3055
- if (topFrames.length > CHUNK_SIZE && figma.ui) {
3056
- try {
3057
- figma.ui.postMessage({ type: "index-chunk", nodes: chunkData });
3058
- } catch(e3) {}
3059
- }
3053
+ var pageNodes = [];
3054
+ try {
3055
+ var page = figma.currentPage;
3056
+ var topFrames = page.children || [];
3057
+ var CHUNK_SIZE = 10;
3058
+ var totalFrames = topFrames.length;
3059
+
3060
+ for (var i = 0; i < topFrames.length; i += CHUNK_SIZE) {
3061
+ var chunk = topFrames.slice(i, i + CHUNK_SIZE);
3062
+ var chunkData = chunk.map(function(n) {
3063
+ return Object.assign(nodeToInfo(n), { childCount: "children" in n ? n.children.length : 0 });
3064
+ });
3065
+ pageNodes = pageNodes.concat(chunkData);
3066
+
3067
+ var nodePct = totalFrames > 0 ? Math.min(40, Math.round(5 + (pageNodes.length / totalFrames) * 35)) : 35;
3068
+ if (figma.ui) {
3069
+ try {
3070
+ figma.ui.postMessage({
3071
+ type: "index-progress",
3072
+ stage: "nodes",
3073
+ percent: nodePct,
3074
+ label: "Layers: " + pageNodes.length + (totalFrames ? "/" + totalFrames : "")
3075
+ });
3076
+ } catch(e) {}
3077
+ }
3078
+
3079
+ // Stream chunk progressively if there are multiple chunks
3080
+ if (topFrames.length > CHUNK_SIZE && figma.ui) {
3081
+ try {
3082
+ figma.ui.postMessage({ type: "index-chunk", nodes: chunkData });
3083
+ } catch(e3) {}
3060
3084
  }
3061
- return collected;
3062
- } catch (e) {}
3063
- return [];
3064
- })();
3065
3085
 
3066
- var stylesPromise = (async function() {
3086
+ // Yield after every chunk so canvas/UI remains completely smooth
3087
+ if (typeof yieldToUI === "function") {
3088
+ await yieldToUI(0);
3089
+ }
3090
+ }
3091
+ } catch (e) {}
3092
+
3093
+ if (typeof yieldToUI === "function") await yieldToUI(0);
3094
+
3095
+ if (figma.ui) {
3067
3096
  try {
3068
- if (handlers.get_styles) return await handlers.get_styles({});
3069
- } catch (e) {}
3070
- return null;
3071
- })();
3097
+ figma.ui.postMessage({ type: "index-progress", stage: "styles", percent: 45, label: "Indexing color & text styles…" });
3098
+ } catch(e) {}
3099
+ }
3100
+
3101
+ var styles = null;
3102
+ try {
3103
+ if (handlers.get_styles) styles = await handlers.get_styles({});
3104
+ } catch (e) {}
3072
3105
 
3073
- var varsPromise = (async function() {
3106
+ if (typeof yieldToUI === "function") await yieldToUI(0);
3107
+
3108
+ if (figma.ui) {
3074
3109
  try {
3075
- if (handlers.get_variables) return await handlers.get_variables({});
3076
- } catch (e) {}
3077
- return null;
3078
- })();
3110
+ figma.ui.postMessage({ type: "index-progress", stage: "variables", percent: 65, label: "Indexing variables & tokens…" });
3111
+ } catch(e) {}
3112
+ }
3113
+
3114
+ var variables = null;
3115
+ try {
3116
+ if (handlers.get_variables) variables = await handlers.get_variables({});
3117
+ } catch (e) {}
3118
+
3119
+ if (typeof yieldToUI === "function") await yieldToUI(0);
3079
3120
 
3080
- var compsPromise = (async function() {
3121
+ if (figma.ui) {
3081
3122
  try {
3082
- if (handlers.get_local_components) return await handlers.get_local_components({});
3083
- } catch (e) {}
3084
- return null;
3085
- })();
3123
+ figma.ui.postMessage({ type: "index-progress", stage: "components", percent: 85, label: "Indexing components & sets…" });
3124
+ } catch(e) {}
3125
+ }
3086
3126
 
3087
- var results = await Promise.all([pageNodesPromise, stylesPromise, varsPromise, compsPromise]);
3088
- var pageNodes = results[0];
3089
- var styles = results[1];
3090
- var variables = results[2];
3091
- var components = results[3];
3127
+ var components = null;
3128
+ try {
3129
+ if (handlers.get_local_components) components = await handlers.get_local_components({});
3130
+ } catch (e) {}
3131
+
3132
+ if (figma.ui) {
3133
+ try {
3134
+ figma.ui.postMessage({ type: "index-progress", stage: "done", percent: 100, label: "Index ready" });
3135
+ } catch(e) {}
3136
+ }
3092
3137
 
3093
3138
  var resolvedSid = figma.fileKey || (function() {
3094
3139
  try { return figma.root.getPluginData("mcp_session_id") || "_default"; } catch(e) { return "_default"; }
@@ -3782,46 +3827,66 @@ handlers.get_styles = async function() {
3782
3827
 
3783
3828
  // get_local_components β€” enhanced component listing with descriptions and properties
3784
3829
  handlers.get_local_components = async function() {
3785
- await figma.loadAllPagesAsync();
3830
+ if (typeof figma.loadAllPagesAsync === "function") {
3831
+ try { await figma.loadAllPagesAsync(); } catch (e) {}
3832
+ }
3786
3833
  var comps = figma.root.findAllWithCriteria({ types: ["COMPONENT"] });
3834
+ if (typeof yieldToUI === "function") await yieldToUI(0);
3787
3835
  var sets = figma.root.findAllWithCriteria({ types: ["COMPONENT_SET"] });
3788
-
3789
- return {
3790
- components: comps.map(function(c) {
3791
- var info = {
3792
- id: c.id, name: c.name, key: c.key || null,
3793
- description: c.description || "",
3794
- setName: (c.parent && c.parent.type === "COMPONENT_SET") ? c.parent.name : null,
3795
- width: Math.round(c.width), height: Math.round(c.height),
3796
- page: c.parent ? (function findPage(n) {
3797
- while (n && n.type !== "PAGE") n = n.parent;
3798
- return n ? n.name : null;
3799
- })(c) : null,
3800
- };
3801
- // Component properties (variant props)
3802
- try {
3803
- if (c.componentPropertyDefinitions) {
3804
- var defs = c.componentPropertyDefinitions;
3805
- var props = {};
3806
- for (var key in defs) {
3807
- if (Object.prototype.hasOwnProperty.call(defs, key)) {
3808
- props[key] = { type: defs[key].type, defaultValue: defs[key].defaultValue };
3809
- if (defs[key].variantOptions) props[key].options = defs[key].variantOptions;
3810
- }
3836
+ if (typeof yieldToUI === "function") await yieldToUI(0);
3837
+
3838
+ var componentList = [];
3839
+ var COMP_BATCH_SIZE = 50;
3840
+ for (var ci = 0; ci < comps.length; ci++) {
3841
+ var c = comps[ci];
3842
+ var info = {
3843
+ id: c.id, name: c.name, key: c.key || null,
3844
+ description: c.description || "",
3845
+ setName: (c.parent && c.parent.type === "COMPONENT_SET") ? c.parent.name : null,
3846
+ width: Math.round(c.width), height: Math.round(c.height),
3847
+ page: c.parent ? (function findPage(n) {
3848
+ while (n && n.type !== "PAGE") n = n.parent;
3849
+ return n ? n.name : null;
3850
+ })(c) : null,
3851
+ };
3852
+ // Component properties (variant props)
3853
+ try {
3854
+ if (c.componentPropertyDefinitions) {
3855
+ var defs = c.componentPropertyDefinitions;
3856
+ var props = {};
3857
+ for (var key in defs) {
3858
+ if (Object.prototype.hasOwnProperty.call(defs, key)) {
3859
+ props[key] = { type: defs[key].type, defaultValue: defs[key].defaultValue };
3860
+ if (defs[key].variantOptions) props[key].options = defs[key].variantOptions;
3811
3861
  }
3812
- info.properties = props;
3813
3862
  }
3814
- } catch(e) { /* skip properties */ }
3815
- return info;
3816
- }),
3817
- componentSets: sets.map(function(s) {
3818
- return {
3819
- id: s.id, name: s.name, key: s.key || null,
3820
- description: s.description || "",
3821
- variantCount: s.children ? s.children.length : 0,
3822
- };
3823
- }),
3824
- total: comps.length + sets.length,
3863
+ info.properties = props;
3864
+ }
3865
+ } catch(e) { /* skip properties */ }
3866
+ componentList.push(info);
3867
+
3868
+ if (ci > 0 && ci % COMP_BATCH_SIZE === 0 && typeof yieldToUI === "function") {
3869
+ await yieldToUI(0);
3870
+ }
3871
+ }
3872
+
3873
+ var componentSets = [];
3874
+ for (var si = 0; si < sets.length; si++) {
3875
+ var s = sets[si];
3876
+ componentSets.push({
3877
+ id: s.id, name: s.name, key: s.key || null,
3878
+ description: s.description || "",
3879
+ variantCount: s.children ? s.children.length : 0,
3880
+ });
3881
+ if (si > 0 && si % COMP_BATCH_SIZE === 0 && typeof yieldToUI === "function") {
3882
+ await yieldToUI(0);
3883
+ }
3884
+ }
3885
+
3886
+ return {
3887
+ components: componentList,
3888
+ componentSets: componentSets,
3889
+ total: componentList.length + componentSets.length,
3825
3890
  };
3826
3891
  };
3827
3892
 
@@ -3904,12 +3969,17 @@ handlers.get_variables = async function() {
3904
3969
  values: values,
3905
3970
  description: v.description || "",
3906
3971
  });
3972
+
3973
+ if (vi > 0 && vi % 50 === 0 && typeof yieldToUI === "function") {
3974
+ await yieldToUI(0);
3975
+ }
3907
3976
  }
3908
3977
  collections.push({
3909
3978
  id: col.id, name: col.name,
3910
3979
  modes: col.modes.map(function(m) { return { id: m.modeId, name: m.name }; }),
3911
3980
  variables: variables,
3912
3981
  });
3982
+ if (typeof yieldToUI === "function") await yieldToUI(0);
3913
3983
  }
3914
3984
  } catch(e) {
3915
3985
  return { error: "Variables API not available: " + e.message, collections: [] };
@@ -5310,9 +5380,19 @@ figma.clientStorage.getAsync("mcp_window_size").then(function(saved) {
5310
5380
  // In Figma, figma.root.id is always "0:0" across all files. To support running
5311
5381
  // multiple plugins concurrently across multiple tabs/files, generate and persist
5312
5382
  // a unique document session ID in document pluginData.
5383
+ function getSavedFileKey() {
5384
+ if (figma.fileKey) return figma.fileKey;
5385
+ try {
5386
+ var saved = figma.root.getPluginData("figma_file_key");
5387
+ if (saved && saved.length > 0) return saved;
5388
+ } catch(e) {}
5389
+ return null;
5390
+ }
5391
+
5313
5392
  function getOrCreateSessionId() {
5314
- if (figma.fileKey) {
5315
- return figma.fileKey;
5393
+ var fk = getSavedFileKey();
5394
+ if (fk) {
5395
+ return fk;
5316
5396
  }
5317
5397
  try {
5318
5398
  var stored = figma.root.getPluginData("mcp_session_id");
@@ -5332,13 +5412,15 @@ function getOrCreateSessionId() {
5332
5412
 
5333
5413
  var currentSessionId = getOrCreateSessionId();
5334
5414
  var currentFileName = figma.root ? figma.root.name : "Untitled";
5415
+ var currentFileKey = getSavedFileKey();
5335
5416
 
5336
5417
  // Broadcast active file / session metadata on startup
5337
5418
  try {
5338
5419
  figma.ui.postMessage({
5339
5420
  type: "session-info",
5340
5421
  sessionId: currentSessionId,
5341
- fileName: currentFileName
5422
+ fileName: currentFileName,
5423
+ fileKey: currentFileKey
5342
5424
  });
5343
5425
  } catch (e) {}
5344
5426
 
@@ -5350,8 +5432,15 @@ figma.on("selectionchange", function() {
5350
5432
  var fullNode = null;
5351
5433
  for (var i = 0; i < Math.min(sel.length, 5); i++) {
5352
5434
  var n = sel[i];
5435
+ // If node is an internal instance sub-layer (starts with I...), find its main component instance or top frame
5436
+ var mainId = n.id;
5437
+ if (mainId.startsWith("I") && mainId.indexOf(";") !== -1) {
5438
+ var parts = mainId.split(";");
5439
+ mainId = parts[parts.length - 1]; // Use real canonical node id
5440
+ }
5353
5441
  summary.push({
5354
5442
  id: n.id,
5443
+ mainId: mainId,
5355
5444
  name: n.name,
5356
5445
  type: n.type,
5357
5446
  width: typeof n.width === "number" ? Math.round(n.width) : undefined,
@@ -5433,20 +5522,23 @@ function onDocChange(event) {
5433
5522
  } catch (e) {}
5434
5523
  }
5435
5524
 
5436
- // In dynamic-page mode, Figma requires figma.loadAllPagesAsync() before registering documentchange
5437
- if (typeof figma.loadAllPagesAsync === "function") {
5438
- figma.loadAllPagesAsync().then(function() {
5525
+ // In dynamic-page mode, Figma requires figma.loadAllPagesAsync() before registering documentchange.
5526
+ // Delay loading all pages slightly so plugin UI opens and paints immediately without UI freeze.
5527
+ setTimeout(function() {
5528
+ if (typeof figma.loadAllPagesAsync === "function") {
5529
+ figma.loadAllPagesAsync().then(function() {
5530
+ try {
5531
+ figma.on("documentchange", onDocChange);
5532
+ } catch (e) {}
5533
+ }).catch(function() {});
5534
+ } else {
5439
5535
  try {
5440
5536
  figma.on("documentchange", onDocChange);
5441
5537
  } catch (e) {}
5442
- }).catch(function() {});
5443
- } else {
5444
- try {
5445
- figma.on("documentchange", onDocChange);
5446
- } catch (e) {}
5447
- }
5538
+ }
5539
+ }, 800);
5448
5540
 
5449
- // Background initial scan after 1.2s startup delay (ensures UI WebSocket is connected)
5541
+ // Background initial scan after 1.8s startup delay (ensures UI is fully rendered and WebSocket connected)
5450
5542
  setTimeout(async function() {
5451
5543
  try {
5452
5544
  if (handlers.index_scan) {
@@ -5461,7 +5553,7 @@ setTimeout(async function() {
5461
5553
  });
5462
5554
  }
5463
5555
  } catch (e) {}
5464
- }, 1200);
5556
+ }, 1800);
5465
5557
 
5466
5558
  // ─── DISPATCHER ───────────────────────────────────────────────────────────────
5467
5559
 
@@ -5508,6 +5600,35 @@ figma.ui.onmessage = async (request) => {
5508
5600
  return;
5509
5601
  }
5510
5602
 
5603
+ // Set and persist Figma File Key
5604
+ if (request.type === "set-file-key") {
5605
+ try {
5606
+ var newFileKey = (request.fileKey || "").trim();
5607
+ if (newFileKey) {
5608
+ figma.root.setPluginData("figma_file_key", newFileKey);
5609
+ currentFileKey = newFileKey;
5610
+ figma.ui.postMessage({
5611
+ type: "session-info",
5612
+ sessionId: currentSessionId,
5613
+ fileName: currentFileName,
5614
+ fileKey: newFileKey
5615
+ });
5616
+ figma.notify("Figma File Key saved!", { timeout: 1500 });
5617
+ }
5618
+ } catch(e) {}
5619
+ return;
5620
+ }
5621
+
5622
+ // Toast notification from UI
5623
+ if (request.type === "notify") {
5624
+ try {
5625
+ if (request.message) {
5626
+ figma.notify(request.message, { timeout: request.timeout || 1500, error: request.error || false });
5627
+ }
5628
+ } catch(e) {}
5629
+ return;
5630
+ }
5631
+
5511
5632
  // Zoom to selection when clicked on selection bar
5512
5633
  if (request.type === "zoom-to-selection") {
5513
5634
  try {
package/plugin/ui.html CHANGED
@@ -98,8 +98,68 @@
98
98
  padding: 6px 8px;
99
99
  flex-shrink: 0;
100
100
  box-shadow: 0 1px 2px rgba(0,0,0,0.4);
101
+ position: relative;
101
102
  }
102
103
 
104
+ /* ── INDEXING PROGRESS BAR (Embedded in Header) ── */
105
+ .index-progress-wrap {
106
+ display: none;
107
+ flex-direction: column;
108
+ gap: 3px;
109
+ padding: 4px 8px 6px;
110
+ background: rgba(147, 129, 255, 0.08);
111
+ border: 1px solid rgba(147, 129, 255, 0.28);
112
+ border-radius: var(--radius-sm);
113
+ margin-top: 1px;
114
+ animation: fadeIn 0.2s ease-out;
115
+ }
116
+ .index-progress-wrap.active {
117
+ display: flex;
118
+ }
119
+ .index-progress-header {
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: space-between;
123
+ font-size: 9px;
124
+ font-weight: 600;
125
+ color: var(--ghost-lavender);
126
+ font-family: var(--font-mono);
127
+ }
128
+ .index-progress-label {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 4px;
132
+ white-space: nowrap;
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
135
+ max-width: 220px;
136
+ }
137
+ .index-progress-label svg {
138
+ animation: spin 1s linear infinite;
139
+ flex-shrink: 0;
140
+ }
141
+ .index-progress-pct {
142
+ color: var(--text-primary);
143
+ flex-shrink: 0;
144
+ }
145
+ .index-progress-track {
146
+ width: 100%;
147
+ height: 3px;
148
+ background: rgba(255, 255, 255, 0.08);
149
+ border-radius: 9999px;
150
+ overflow: hidden;
151
+ }
152
+ .index-progress-fill {
153
+ height: 100%;
154
+ width: 0%;
155
+ background: linear-gradient(90deg, #7c3aed, #9381ff, #34d399);
156
+ border-radius: 9999px;
157
+ transition: width 0.2s ease-out;
158
+ box-shadow: 0 0 8px rgba(147, 129, 255, 0.6);
159
+ }
160
+
161
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: translateY(0); } }
162
+
103
163
  .brand { display: flex; align-items: center; gap: 7px; min-width: 0; }
104
164
 
105
165
  .brand-logo {
@@ -517,19 +577,82 @@
517
577
  pointer-events: auto;
518
578
  }
519
579
 
520
- /* RESIZE GRIP */
521
- .resize-grip {
522
- position: fixed; right: 0; bottom: 0;
523
- width: 18px; height: 18px;
524
- cursor: nwse-resize; opacity: 0.35;
580
+ /* ── CUSTOM IN-PLUGIN MODAL DIALOG (No window.prompt dependency) ── */
581
+ .modal-overlay {
582
+ position: fixed;
583
+ top: 0; left: 0; right: 0; bottom: 0;
584
+ background: rgba(8, 9, 13, 0.75);
585
+ backdrop-filter: blur(5px);
586
+ display: flex;
587
+ align-items: center;
588
+ justify-content: center;
589
+ padding: 12px;
590
+ z-index: 999;
591
+ opacity: 0;
592
+ pointer-events: none;
593
+ transition: opacity var(--transition);
594
+ }
595
+ .modal-overlay.show {
596
+ opacity: 1;
597
+ pointer-events: auto;
598
+ }
599
+ .modal-card {
600
+ background: var(--surface-card);
601
+ border: 1px solid var(--border-light);
602
+ border-radius: var(--radius-md);
603
+ box-shadow: 0 16px 36px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.08) inset;
604
+ width: 100%;
605
+ max-width: 290px;
606
+ padding: 12px;
607
+ display: flex;
608
+ flex-direction: column;
609
+ gap: 10px;
610
+ transform: scale(0.95);
611
+ transition: transform var(--transition);
612
+ }
613
+ .modal-overlay.show .modal-card {
614
+ transform: scale(1);
615
+ }
616
+ .modal-title-row {
617
+ display: flex;
618
+ align-items: center;
619
+ justify-content: space-between;
620
+ }
621
+ .modal-title {
622
+ font-size: 11px;
623
+ font-weight: 700;
624
+ color: var(--text-primary);
625
+ display: flex;
626
+ align-items: center;
627
+ gap: 5px;
628
+ }
629
+ .modal-desc {
630
+ font-size: 9.5px;
525
631
  color: var(--text-secondary);
526
- transition: opacity var(--transition), color var(--transition);
527
- z-index: 1000;
528
- display: flex; align-items: flex-end; justify-content: flex-end;
529
- padding: 3px;
530
- touch-action: none;
632
+ line-height: 1.35;
633
+ }
634
+ .modal-input {
635
+ width: 100%;
636
+ background: var(--surface-base);
637
+ border: 1px solid var(--border-light);
638
+ border-radius: var(--radius-xs);
639
+ color: var(--text-primary);
640
+ font-family: var(--font-mono);
641
+ font-size: 9.5px;
642
+ padding: 6px 8px;
643
+ outline: none;
644
+ transition: border-color var(--transition);
645
+ }
646
+ .modal-input:focus {
647
+ border-color: var(--ghost-lavender);
648
+ box-shadow: 0 0 0 2px var(--ghost-glow);
649
+ }
650
+ .modal-actions {
651
+ display: flex;
652
+ justify-content: flex-end;
653
+ gap: 6px;
654
+ margin-top: 2px;
531
655
  }
532
- .resize-grip:hover { opacity: 1; color: var(--ghost-mint); }
533
656
  </style>
534
657
  </head>
535
658
  <body>
@@ -548,7 +671,7 @@
548
671
  <div class="brand-info">
549
672
  <div class="brand-title-row">
550
673
  <span class="brand-name">Figma MCP</span>
551
- <span class="brand-ver">v2.8</span>
674
+ <span class="brand-ver">v3.0.0</span>
552
675
  </div>
553
676
  <span class="brand-file" id="doc-display" title="Active Document">Ready</span>
554
677
  </div>
@@ -571,6 +694,14 @@
571
694
  </svg>
572
695
  </button>
573
696
 
697
+ <!-- Copy File / Session Link -->
698
+ <button class="header-icon-btn" id="file-link-btn" onclick="copyFileLink(event)" title="πŸ”— Set / Copy Figma File Link">
699
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
700
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
701
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
702
+ </svg>
703
+ </button>
704
+
574
705
  <!-- Connection Status Pill -->
575
706
  <div class="status-pill" id="status-pill" onclick="handleStatusClick()" title="Connection Status (Click for details)">
576
707
  <div class="dot yellow" id="dot"></div>
@@ -579,6 +710,22 @@
579
710
  </div>
580
711
  </header>
581
712
 
713
+ <!-- ── INDEXING PROGRESS HUD ── -->
714
+ <div class="index-progress-wrap" id="index-progress-wrap">
715
+ <div class="index-progress-header">
716
+ <span class="index-progress-label" id="index-progress-label">
717
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
718
+ <polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
719
+ </svg>
720
+ <span id="index-progress-text">Indexing canvas layers…</span>
721
+ </span>
722
+ <span class="index-progress-pct" id="index-progress-pct">0%</span>
723
+ </div>
724
+ <div class="index-progress-track">
725
+ <div class="index-progress-fill" id="index-progress-fill"></div>
726
+ </div>
727
+ </div>
728
+
582
729
  <!-- ── SELECTION INSPECTOR HERO CARD ── -->
583
730
  <div class="selection-card" id="selection-card">
584
731
  <div class="selection-main" onclick="focusSelection()" title="Click to center view on selection">
@@ -602,6 +749,14 @@
602
749
  </svg>
603
750
  </button>
604
751
 
752
+ <!-- Copy Link to Selection Frame button -->
753
+ <button class="act-btn icon-only" id="link-btn" onclick="copySelectionLink(event)" title="πŸ”— Copy link to selection frame">
754
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
755
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
756
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
757
+ </svg>
758
+ </button>
759
+
605
760
  <!-- Focus / Center on Canvas button -->
606
761
  <button class="act-btn icon-only" onclick="focusSelection()" title="🎯 Center view on canvas">
607
762
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -613,7 +768,15 @@
613
768
  </svg>
614
769
  </button>
615
770
 
616
- <!-- Copy Node ID badge -->
771
+ <!-- Copy Node ID button -->
772
+ <button class="act-btn icon-only" id="copy-id-btn" onclick="copySelectionId(event)" title="πŸ“‹ Copy Figma Node ID">
773
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
774
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
775
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
776
+ </svg>
777
+ </button>
778
+
779
+ <!-- Node Type Badge button (also clickable to copy ID) -->
617
780
  <button class="act-btn badge-btn" id="selection-badge" onclick="copySelectionId(event)" title="πŸ“‹ Click to copy Figma Node ID">
618
781
  FRAME
619
782
  </button>
@@ -667,6 +830,29 @@
667
830
 
668
831
  </div>
669
832
 
833
+ <!-- File Key Input Modal Dialog -->
834
+ <div class="modal-overlay" id="key-modal-overlay">
835
+ <div class="modal-card">
836
+ <div class="modal-title-row">
837
+ <div class="modal-title">
838
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--ghost-lavender)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
839
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
840
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
841
+ </svg>
842
+ Set Figma File Link
843
+ </div>
844
+ </div>
845
+ <div class="modal-desc">
846
+ Paste full Figma file URL or File Key to generate real browser links for your layers:
847
+ </div>
848
+ <input type="text" class="modal-input" id="file-key-input" placeholder="https://www.figma.com/design/..." spellcheck="false" autocomplete="off" />
849
+ <div class="modal-actions">
850
+ <button class="btn" onclick="closeKeyModal()">Cancel</button>
851
+ <button class="btn btn-primary" onclick="saveFileKeyFromModal()">Save & Copy</button>
852
+ </div>
853
+ </div>
854
+ </div>
855
+
670
856
  <!-- Floating Toast Component -->
671
857
  <div class="toast" id="toast">
672
858
  <span id="toast-icon">βœ“</span>
@@ -691,6 +877,7 @@
691
877
  var lastSuccessfulPollAt = Date.now();
692
878
  var sessionId = null;
693
879
  var fileName = null;
880
+ var fileKey = null;
694
881
 
695
882
  var writeCount = 0, readCount = 0, errCount = 0;
696
883
  var totalLogEvents = 0;
@@ -708,6 +895,10 @@
708
895
  var ramEl = document.getElementById("ram-display");
709
896
  var reindexBtn = document.getElementById("reindex-btn");
710
897
  var reindexBtnLabel = document.getElementById("reindex-btn-label");
898
+ var indexProgressWrap = document.getElementById("index-progress-wrap");
899
+ var indexProgressText = document.getElementById("index-progress-text");
900
+ var indexProgressPct = document.getElementById("index-progress-pct");
901
+ var indexProgressFill = document.getElementById("index-progress-fill");
711
902
 
712
903
  var selCardEl = document.getElementById("selection-card");
713
904
  var selIconEl = document.getElementById("selection-icon");
@@ -722,6 +913,7 @@
722
913
 
723
914
  var currentSelectionCount = 0;
724
915
  var currentSelectionId = null;
916
+ var currentSelectionMainId = null;
725
917
  var currentSelectionName = "";
726
918
 
727
919
  function showToast(msg, icon) {
@@ -791,15 +983,149 @@
791
983
  parent.postMessage({ pluginMessage: { type: "export-selection-image", scale: 2 } }, "*");
792
984
  }
793
985
 
986
+ function copyTextFallback(text) {
987
+ try {
988
+ var textarea = document.createElement("textarea");
989
+ textarea.value = text;
990
+ textarea.style.position = "fixed";
991
+ textarea.style.left = "-9999px";
992
+ textarea.style.top = "-9999px";
993
+ textarea.style.opacity = "0";
994
+ document.body.appendChild(textarea);
995
+ textarea.focus();
996
+ textarea.select();
997
+ var successful = document.execCommand("copy");
998
+ document.body.removeChild(textarea);
999
+ return successful;
1000
+ } catch (err) {
1001
+ return false;
1002
+ }
1003
+ }
1004
+
1005
+ function openKeyModal(onSuccess) {
1006
+ var modal = document.getElementById("key-modal-overlay");
1007
+ var input = document.getElementById("file-key-input");
1008
+ if (modal && input) {
1009
+ input.value = fileKey ? ("https://www.figma.com/design/" + fileKey) : "";
1010
+ modal.classList.add("show");
1011
+ window.__onKeySaved = onSuccess;
1012
+ setTimeout(function() { input.focus(); input.select(); }, 50);
1013
+ }
1014
+ }
1015
+
1016
+ function closeKeyModal() {
1017
+ var modal = document.getElementById("key-modal-overlay");
1018
+ if (modal) modal.classList.remove("show");
1019
+ window.__onKeySaved = null;
1020
+ }
1021
+
1022
+ function saveFileKeyFromModal() {
1023
+ var input = document.getElementById("file-key-input");
1024
+ var val = input ? input.value.trim() : "";
1025
+ if (val) {
1026
+ var match = val.match(/(?:file|design)\/([a-zA-Z0-9]+)/);
1027
+ var parsedKey = match ? match[1] : val;
1028
+ fileKey = parsedKey;
1029
+ parent.postMessage({ pluginMessage: { type: "set-file-key", fileKey: parsedKey } }, "*");
1030
+ showToast("File Key saved!", "πŸ”—");
1031
+ }
1032
+ var cb = window.__onKeySaved;
1033
+ closeKeyModal();
1034
+ if (typeof cb === "function") {
1035
+ cb(fileKey);
1036
+ }
1037
+ }
1038
+
1039
+ function copyFileLink(event) {
1040
+ if (event) {
1041
+ event.stopPropagation();
1042
+ event.preventDefault();
1043
+ }
1044
+ openKeyModal(function(savedKey) {
1045
+ if (savedKey) {
1046
+ showToast("File link updated", "πŸ”—");
1047
+ }
1048
+ });
1049
+ }
1050
+
1051
+ function copySelectionLink(event) {
1052
+ if (event) {
1053
+ event.stopPropagation();
1054
+ event.preventDefault();
1055
+ }
1056
+ if (!currentSelectionId) {
1057
+ showToast("No selection active", "⚠️");
1058
+ try { parent.postMessage({ pluginMessage: { type: "notify", message: "⚠️ No frame selected to copy link", error: true } }, "*"); } catch(e) {}
1059
+ return;
1060
+ }
1061
+
1062
+ if (!fileKey) {
1063
+ openKeyModal(function(savedKey) {
1064
+ executeCopyLink(savedKey);
1065
+ });
1066
+ return;
1067
+ }
1068
+
1069
+ executeCopyLink(fileKey);
1070
+ }
1071
+
1072
+ function executeCopyLink(effectiveKey) {
1073
+ if (!currentSelectionId) return;
1074
+
1075
+ var targetId = currentSelectionMainId || currentSelectionId;
1076
+ var figmaNodeId = targetId.replace(/:/g, "-");
1077
+
1078
+ // Figma web link uses a sanitized slug of the file name (or untitled)
1079
+ var cleanSlug = (fileName || "file")
1080
+ .replace(/[\[\]\(\)\{\}]/g, "")
1081
+ .trim()
1082
+ .replace(/\s+/g, "-");
1083
+ var encodedSlug = encodeURIComponent(cleanSlug);
1084
+
1085
+ var link = "";
1086
+ if (effectiveKey) {
1087
+ link = "https://www.figma.com/design/" + effectiveKey + "/" + encodedSlug + "?node-id=" + figmaNodeId;
1088
+ } else {
1089
+ link = "https://www.figma.com/design/draft/" + encodedSlug + "?node-id=" + figmaNodeId;
1090
+ }
1091
+
1092
+ var copied = copyTextFallback(link);
1093
+ if (!copied && navigator && navigator.clipboard && navigator.clipboard.writeText) {
1094
+ navigator.clipboard.writeText(link).catch(function() {});
1095
+ }
1096
+
1097
+ showToast("Link: " + figmaNodeId, "πŸ”—");
1098
+ try {
1099
+ parent.postMessage({
1100
+ pluginMessage: {
1101
+ type: "notify",
1102
+ message: "πŸ”— Link copied to clipboard! (" + figmaNodeId + ")"
1103
+ }
1104
+ }, "*");
1105
+ } catch(e) {}
1106
+ }
1107
+
794
1108
  function copySelectionId(event) {
795
- if (event) event.stopPropagation();
1109
+ if (event) {
1110
+ event.stopPropagation();
1111
+ event.preventDefault();
1112
+ }
796
1113
  if (!currentSelectionId) return;
797
- if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
1114
+ var copied = copyTextFallback(currentSelectionId);
1115
+ if (!copied && navigator && navigator.clipboard && navigator.clipboard.writeText) {
798
1116
  navigator.clipboard.writeText(currentSelectionId).then(function() {
799
1117
  showToast("Node ID: " + currentSelectionId + " copied", "πŸ“‹");
1118
+ try {
1119
+ parent.postMessage({ pluginMessage: { type: "notify", message: "πŸ“‹ Node ID: " + currentSelectionId + " copied" } }, "*");
1120
+ } catch(e) {}
800
1121
  }).catch(function() {
801
1122
  prompt("Copy Node ID:", currentSelectionId);
802
1123
  });
1124
+ } else if (copied) {
1125
+ showToast("Node ID: " + currentSelectionId + " copied", "πŸ“‹");
1126
+ try {
1127
+ parent.postMessage({ pluginMessage: { type: "notify", message: "πŸ“‹ Node ID: " + currentSelectionId + " copied" } }, "*");
1128
+ } catch(e) {}
803
1129
  } else {
804
1130
  prompt("Copy Node ID:", currentSelectionId);
805
1131
  }
@@ -1259,6 +1585,7 @@
1259
1585
  var list = msg.selection || [];
1260
1586
  currentSelectionCount = count;
1261
1587
  currentSelectionId = (count === 1 && list[0]) ? list[0].id : null;
1588
+ currentSelectionMainId = (count === 1 && list[0]) ? (list[0].mainId || list[0].id) : null;
1262
1589
  currentSelectionName = (count === 1 && list[0]) ? (list[0].name || "Layer") : "";
1263
1590
 
1264
1591
  if (count === 0) {
@@ -1302,6 +1629,7 @@
1302
1629
  var prevSid = sessionId;
1303
1630
  sessionId = msg.sessionId || null;
1304
1631
  fileName = msg.fileName || null;
1632
+ fileKey = msg.fileKey || null;
1305
1633
  if (fileName && docDispEl) {
1306
1634
  docDispEl.textContent = fileName;
1307
1635
  docDispEl.title = fileName + " (" + (sessionId || "active session") + ")";
@@ -1373,6 +1701,15 @@
1373
1701
  return;
1374
1702
  }
1375
1703
 
1704
+ if (msg.type === "index-progress") {
1705
+ if (reindexBtn) reindexBtn.classList.add("indexing");
1706
+ if (indexProgressWrap) indexProgressWrap.classList.add("active");
1707
+ if (indexProgressText && msg.label) indexProgressText.textContent = msg.label;
1708
+ if (indexProgressPct && msg.percent !== undefined) indexProgressPct.textContent = msg.percent + "%";
1709
+ if (indexProgressFill && msg.percent !== undefined) indexProgressFill.style.width = Math.min(100, Math.max(0, msg.percent)) + "%";
1710
+ return;
1711
+ }
1712
+
1376
1713
  if (msg.type === "index-chunk") {
1377
1714
  if (ws && ws.readyState === WebSocket.OPEN) {
1378
1715
  try { ws.send(JSON.stringify({ type: "index-chunk", nodes: msg.nodes })); } catch(e) {}
@@ -1389,6 +1726,13 @@
1389
1726
 
1390
1727
  if (msg.type === "index-update") {
1391
1728
  if (reindexBtn) reindexBtn.classList.remove("indexing");
1729
+ if (indexProgressPct) indexProgressPct.textContent = "100%";
1730
+ if (indexProgressFill) indexProgressFill.style.width = "100%";
1731
+ if (indexProgressText) indexProgressText.textContent = "Index ready";
1732
+ setTimeout(function() {
1733
+ if (indexProgressWrap) indexProgressWrap.classList.remove("active");
1734
+ }, 1200);
1735
+
1392
1736
  var elapsed = msg.startMs ? (Date.now() - msg.startMs) : 0;
1393
1737
  showToast("Indexed in " + elapsed + "ms", "βœ“");
1394
1738
  if (ws && ws.readyState === WebSocket.OPEN) {