@muloka/figma-console-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (443) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +898 -0
  3. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
  4. package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
  5. package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
  6. package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
  7. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
  8. package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
  9. package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
  10. package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
  11. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
  12. package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
  13. package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
  14. package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
  15. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
  16. package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
  17. package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
  18. package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
  19. package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
  20. package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
  21. package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
  22. package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
  23. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
  24. package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
  25. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
  26. package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
  27. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
  28. package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
  29. package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
  30. package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
  31. package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
  32. package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
  33. package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
  34. package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
  35. package/dist/apps/design-system-dashboard/server.d.ts +24 -0
  36. package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
  37. package/dist/apps/design-system-dashboard/server.js +160 -0
  38. package/dist/apps/design-system-dashboard/server.js.map +1 -0
  39. package/dist/apps/token-browser/server.d.ts +26 -0
  40. package/dist/apps/token-browser/server.d.ts.map +1 -0
  41. package/dist/apps/token-browser/server.js +137 -0
  42. package/dist/apps/token-browser/server.js.map +1 -0
  43. package/dist/browser/base.d.ts +58 -0
  44. package/dist/browser/base.d.ts.map +1 -0
  45. package/dist/browser/base.js +6 -0
  46. package/dist/browser/base.js.map +1 -0
  47. package/dist/browser/local.d.ts +87 -0
  48. package/dist/browser/local.d.ts.map +1 -0
  49. package/dist/browser/local.js +318 -0
  50. package/dist/browser/local.js.map +1 -0
  51. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
  52. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
  53. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
  54. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
  55. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
  56. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
  57. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
  58. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
  59. package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
  60. package/dist/cloudflare/apps/token-browser/server.js +136 -0
  61. package/dist/cloudflare/browser-manager.js +157 -0
  62. package/dist/cloudflare/core/accessibility-tools.js +361 -0
  63. package/dist/cloudflare/core/annotation-tools.js +230 -0
  64. package/dist/cloudflare/core/cloud-websocket-connector.js +419 -0
  65. package/dist/cloudflare/core/cloud-websocket-relay.js +198 -0
  66. package/dist/cloudflare/core/comment-tools.js +292 -0
  67. package/dist/cloudflare/core/config.js +153 -0
  68. package/dist/cloudflare/core/console-monitor.js +427 -0
  69. package/dist/cloudflare/core/deep-component-tools.js +128 -0
  70. package/dist/cloudflare/core/design-code-tools.js +2793 -0
  71. package/dist/cloudflare/core/design-system-manifest.js +265 -0
  72. package/dist/cloudflare/core/design-system-tools.js +882 -0
  73. package/dist/cloudflare/core/diagnose-tool.js +100 -0
  74. package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
  75. package/dist/cloudflare/core/diff/diff-engine.js +334 -0
  76. package/dist/cloudflare/core/diff/property-compare.js +36 -0
  77. package/dist/cloudflare/core/diff/version-cache.js +74 -0
  78. package/dist/cloudflare/core/enrichment/enrichment-service.js +374 -0
  79. package/dist/cloudflare/core/enrichment/index.js +7 -0
  80. package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
  81. package/dist/cloudflare/core/enrichment/style-resolver.js +346 -0
  82. package/dist/cloudflare/core/figjam-tools.js +547 -0
  83. package/dist/cloudflare/core/figma-api.js +509 -0
  84. package/dist/cloudflare/core/figma-connector.js +7 -0
  85. package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
  86. package/dist/cloudflare/core/figma-style-extractor.js +311 -0
  87. package/dist/cloudflare/core/figma-tools.js +3302 -0
  88. package/dist/cloudflare/core/identity.js +96 -0
  89. package/dist/cloudflare/core/library-tools.js +580 -0
  90. package/dist/cloudflare/core/logger.js +53 -0
  91. package/dist/cloudflare/core/port-discovery.js +935 -0
  92. package/dist/cloudflare/core/resolve-package-root.js +11 -0
  93. package/dist/cloudflare/core/slides-tools.js +714 -0
  94. package/dist/cloudflare/core/slot-tools.js +334 -0
  95. package/dist/cloudflare/core/snippet-injector.js +96 -0
  96. package/dist/cloudflare/core/tokens/alias-resolver.js +205 -0
  97. package/dist/cloudflare/core/tokens/config.js +294 -0
  98. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  99. package/dist/cloudflare/core/tokens/figma-converter.js +323 -0
  100. package/dist/cloudflare/core/tokens/formatters/css-vars.js +338 -0
  101. package/dist/cloudflare/core/tokens/formatters/dtcg.js +376 -0
  102. package/dist/cloudflare/core/tokens/formatters/index.js +45 -0
  103. package/dist/cloudflare/core/tokens/formatters/json.js +205 -0
  104. package/dist/cloudflare/core/tokens/formatters/less.js +4 -0
  105. package/dist/cloudflare/core/tokens/formatters/scss.js +258 -0
  106. package/dist/cloudflare/core/tokens/formatters/stubs.js +13 -0
  107. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +213 -0
  108. package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +237 -0
  109. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +335 -0
  110. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +256 -0
  111. package/dist/cloudflare/core/tokens/formatters/ts-module.js +198 -0
  112. package/dist/cloudflare/core/tokens/index.js +16 -0
  113. package/dist/cloudflare/core/tokens/parsers/css-vars.js +4 -0
  114. package/dist/cloudflare/core/tokens/parsers/dtcg.js +280 -0
  115. package/dist/cloudflare/core/tokens/parsers/index.js +138 -0
  116. package/dist/cloudflare/core/tokens/parsers/json.js +7 -0
  117. package/dist/cloudflare/core/tokens/parsers/scss.js +4 -0
  118. package/dist/cloudflare/core/tokens/parsers/stubs.js +20 -0
  119. package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +4 -0
  120. package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +4 -0
  121. package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +4 -0
  122. package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +4 -0
  123. package/dist/cloudflare/core/tokens/schemas.js +152 -0
  124. package/dist/cloudflare/core/tokens/transforms/color.js +12 -0
  125. package/dist/cloudflare/core/tokens/transforms/index.js +29 -0
  126. package/dist/cloudflare/core/tokens/transforms/size.js +7 -0
  127. package/dist/cloudflare/core/tokens/types.js +18 -0
  128. package/dist/cloudflare/core/tokens-tools.js +1788 -0
  129. package/dist/cloudflare/core/types/design-code.js +4 -0
  130. package/dist/cloudflare/core/types/enriched.js +5 -0
  131. package/dist/cloudflare/core/types/index.js +4 -0
  132. package/dist/cloudflare/core/variable-resolver.js +85 -0
  133. package/dist/cloudflare/core/version-tools.js +1199 -0
  134. package/dist/cloudflare/core/websocket-connector.js +436 -0
  135. package/dist/cloudflare/core/websocket-server.js +956 -0
  136. package/dist/cloudflare/core/write-tools.js +2506 -0
  137. package/dist/cloudflare/index.js +3010 -0
  138. package/dist/core/accessibility-tools.d.ts +21 -0
  139. package/dist/core/accessibility-tools.d.ts.map +1 -0
  140. package/dist/core/accessibility-tools.js +362 -0
  141. package/dist/core/accessibility-tools.js.map +1 -0
  142. package/dist/core/annotation-tools.d.ts +14 -0
  143. package/dist/core/annotation-tools.d.ts.map +1 -0
  144. package/dist/core/annotation-tools.js +231 -0
  145. package/dist/core/annotation-tools.js.map +1 -0
  146. package/dist/core/comment-tools.d.ts +11 -0
  147. package/dist/core/comment-tools.d.ts.map +1 -0
  148. package/dist/core/comment-tools.js +293 -0
  149. package/dist/core/comment-tools.js.map +1 -0
  150. package/dist/core/config.d.ts +17 -0
  151. package/dist/core/config.d.ts.map +1 -0
  152. package/dist/core/config.js +154 -0
  153. package/dist/core/config.js.map +1 -0
  154. package/dist/core/console-monitor.d.ts +82 -0
  155. package/dist/core/console-monitor.d.ts.map +1 -0
  156. package/dist/core/console-monitor.js +428 -0
  157. package/dist/core/console-monitor.js.map +1 -0
  158. package/dist/core/deep-component-tools.d.ts +14 -0
  159. package/dist/core/deep-component-tools.d.ts.map +1 -0
  160. package/dist/core/deep-component-tools.js +129 -0
  161. package/dist/core/deep-component-tools.js.map +1 -0
  162. package/dist/core/design-code-tools.d.ts +116 -0
  163. package/dist/core/design-code-tools.d.ts.map +1 -0
  164. package/dist/core/design-code-tools.js +2794 -0
  165. package/dist/core/design-code-tools.js.map +1 -0
  166. package/dist/core/design-system-manifest.d.ts +273 -0
  167. package/dist/core/design-system-manifest.d.ts.map +1 -0
  168. package/dist/core/design-system-manifest.js +266 -0
  169. package/dist/core/design-system-manifest.js.map +1 -0
  170. package/dist/core/design-system-tools.d.ts +67 -0
  171. package/dist/core/design-system-tools.d.ts.map +1 -0
  172. package/dist/core/design-system-tools.js +883 -0
  173. package/dist/core/design-system-tools.js.map +1 -0
  174. package/dist/core/diagnose-tool.d.ts +41 -0
  175. package/dist/core/diagnose-tool.d.ts.map +1 -0
  176. package/dist/core/diagnose-tool.js +101 -0
  177. package/dist/core/diagnose-tool.js.map +1 -0
  178. package/dist/core/diff/changelog-formatter.d.ts +35 -0
  179. package/dist/core/diff/changelog-formatter.d.ts.map +1 -0
  180. package/dist/core/diff/changelog-formatter.js +276 -0
  181. package/dist/core/diff/changelog-formatter.js.map +1 -0
  182. package/dist/core/diff/diff-engine.d.ts +127 -0
  183. package/dist/core/diff/diff-engine.d.ts.map +1 -0
  184. package/dist/core/diff/diff-engine.js +335 -0
  185. package/dist/core/diff/diff-engine.js.map +1 -0
  186. package/dist/core/diff/property-compare.d.ts +19 -0
  187. package/dist/core/diff/property-compare.d.ts.map +1 -0
  188. package/dist/core/diff/property-compare.js +37 -0
  189. package/dist/core/diff/property-compare.js.map +1 -0
  190. package/dist/core/diff/version-cache.d.ts +40 -0
  191. package/dist/core/diff/version-cache.d.ts.map +1 -0
  192. package/dist/core/diff/version-cache.js +75 -0
  193. package/dist/core/diff/version-cache.js.map +1 -0
  194. package/dist/core/enrichment/enrichment-service.d.ts +52 -0
  195. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
  196. package/dist/core/enrichment/enrichment-service.js +375 -0
  197. package/dist/core/enrichment/enrichment-service.js.map +1 -0
  198. package/dist/core/enrichment/index.d.ts +8 -0
  199. package/dist/core/enrichment/index.d.ts.map +1 -0
  200. package/dist/core/enrichment/index.js +8 -0
  201. package/dist/core/enrichment/index.js.map +1 -0
  202. package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
  203. package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
  204. package/dist/core/enrichment/relationship-mapper.js +352 -0
  205. package/dist/core/enrichment/relationship-mapper.js.map +1 -0
  206. package/dist/core/enrichment/style-resolver.d.ts +85 -0
  207. package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
  208. package/dist/core/enrichment/style-resolver.js +347 -0
  209. package/dist/core/enrichment/style-resolver.js.map +1 -0
  210. package/dist/core/figjam-tools.d.ts +8 -0
  211. package/dist/core/figjam-tools.d.ts.map +1 -0
  212. package/dist/core/figjam-tools.js +548 -0
  213. package/dist/core/figjam-tools.js.map +1 -0
  214. package/dist/core/figma-api.d.ts +254 -0
  215. package/dist/core/figma-api.d.ts.map +1 -0
  216. package/dist/core/figma-api.js +510 -0
  217. package/dist/core/figma-api.js.map +1 -0
  218. package/dist/core/figma-connector.d.ts +214 -0
  219. package/dist/core/figma-connector.d.ts.map +1 -0
  220. package/dist/core/figma-connector.js +8 -0
  221. package/dist/core/figma-connector.js.map +1 -0
  222. package/dist/core/figma-desktop-connector.d.ts +312 -0
  223. package/dist/core/figma-desktop-connector.d.ts.map +1 -0
  224. package/dist/core/figma-desktop-connector.js +1298 -0
  225. package/dist/core/figma-desktop-connector.js.map +1 -0
  226. package/dist/core/figma-reconstruction-spec.d.ts +166 -0
  227. package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
  228. package/dist/core/figma-reconstruction-spec.js +403 -0
  229. package/dist/core/figma-reconstruction-spec.js.map +1 -0
  230. package/dist/core/figma-style-extractor.d.ts +76 -0
  231. package/dist/core/figma-style-extractor.d.ts.map +1 -0
  232. package/dist/core/figma-style-extractor.js +312 -0
  233. package/dist/core/figma-style-extractor.js.map +1 -0
  234. package/dist/core/figma-tools.d.ts +22 -0
  235. package/dist/core/figma-tools.d.ts.map +1 -0
  236. package/dist/core/figma-tools.js +3303 -0
  237. package/dist/core/figma-tools.js.map +1 -0
  238. package/dist/core/identity.d.ts +41 -0
  239. package/dist/core/identity.d.ts.map +1 -0
  240. package/dist/core/identity.js +97 -0
  241. package/dist/core/identity.js.map +1 -0
  242. package/dist/core/library-tools.d.ts +17 -0
  243. package/dist/core/library-tools.d.ts.map +1 -0
  244. package/dist/core/library-tools.js +581 -0
  245. package/dist/core/library-tools.js.map +1 -0
  246. package/dist/core/logger.d.ts +22 -0
  247. package/dist/core/logger.d.ts.map +1 -0
  248. package/dist/core/logger.js +54 -0
  249. package/dist/core/logger.js.map +1 -0
  250. package/dist/core/port-discovery.d.ts +211 -0
  251. package/dist/core/port-discovery.d.ts.map +1 -0
  252. package/dist/core/port-discovery.js +936 -0
  253. package/dist/core/port-discovery.js.map +1 -0
  254. package/dist/core/resolve-package-root.d.ts +2 -0
  255. package/dist/core/resolve-package-root.d.ts.map +1 -0
  256. package/dist/core/resolve-package-root.js +12 -0
  257. package/dist/core/resolve-package-root.js.map +1 -0
  258. package/dist/core/slides-tools.d.ts +8 -0
  259. package/dist/core/slides-tools.d.ts.map +1 -0
  260. package/dist/core/slides-tools.js +715 -0
  261. package/dist/core/slides-tools.js.map +1 -0
  262. package/dist/core/slot-tools.d.ts +7 -0
  263. package/dist/core/slot-tools.d.ts.map +1 -0
  264. package/dist/core/slot-tools.js +335 -0
  265. package/dist/core/slot-tools.js.map +1 -0
  266. package/dist/core/snippet-injector.d.ts +24 -0
  267. package/dist/core/snippet-injector.d.ts.map +1 -0
  268. package/dist/core/snippet-injector.js +97 -0
  269. package/dist/core/snippet-injector.js.map +1 -0
  270. package/dist/core/tokens/alias-resolver.d.ts +97 -0
  271. package/dist/core/tokens/alias-resolver.d.ts.map +1 -0
  272. package/dist/core/tokens/alias-resolver.js +206 -0
  273. package/dist/core/tokens/alias-resolver.js.map +1 -0
  274. package/dist/core/tokens/config.d.ts +380 -0
  275. package/dist/core/tokens/config.d.ts.map +1 -0
  276. package/dist/core/tokens/config.js +295 -0
  277. package/dist/core/tokens/config.js.map +1 -0
  278. package/dist/core/tokens/dialect.d.ts +107 -0
  279. package/dist/core/tokens/dialect.d.ts.map +1 -0
  280. package/dist/core/tokens/dialect.js +233 -0
  281. package/dist/core/tokens/dialect.js.map +1 -0
  282. package/dist/core/tokens/figma-converter.d.ts +102 -0
  283. package/dist/core/tokens/figma-converter.d.ts.map +1 -0
  284. package/dist/core/tokens/figma-converter.js +324 -0
  285. package/dist/core/tokens/figma-converter.js.map +1 -0
  286. package/dist/core/tokens/formatters/css-vars.d.ts +24 -0
  287. package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -0
  288. package/dist/core/tokens/formatters/css-vars.js +339 -0
  289. package/dist/core/tokens/formatters/css-vars.js.map +1 -0
  290. package/dist/core/tokens/formatters/dtcg.d.ts +28 -0
  291. package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -0
  292. package/dist/core/tokens/formatters/dtcg.js +377 -0
  293. package/dist/core/tokens/formatters/dtcg.js.map +1 -0
  294. package/dist/core/tokens/formatters/index.d.ts +30 -0
  295. package/dist/core/tokens/formatters/index.d.ts.map +1 -0
  296. package/dist/core/tokens/formatters/index.js +46 -0
  297. package/dist/core/tokens/formatters/index.js.map +1 -0
  298. package/dist/core/tokens/formatters/json.d.ts +37 -0
  299. package/dist/core/tokens/formatters/json.d.ts.map +1 -0
  300. package/dist/core/tokens/formatters/json.js +206 -0
  301. package/dist/core/tokens/formatters/json.js.map +1 -0
  302. package/dist/core/tokens/formatters/less.d.ts +4 -0
  303. package/dist/core/tokens/formatters/less.d.ts.map +1 -0
  304. package/dist/core/tokens/formatters/less.js +5 -0
  305. package/dist/core/tokens/formatters/less.js.map +1 -0
  306. package/dist/core/tokens/formatters/scss.d.ts +26 -0
  307. package/dist/core/tokens/formatters/scss.d.ts.map +1 -0
  308. package/dist/core/tokens/formatters/scss.js +259 -0
  309. package/dist/core/tokens/formatters/scss.js.map +1 -0
  310. package/dist/core/tokens/formatters/stubs.d.ts +9 -0
  311. package/dist/core/tokens/formatters/stubs.d.ts.map +1 -0
  312. package/dist/core/tokens/formatters/stubs.js +14 -0
  313. package/dist/core/tokens/formatters/stubs.js.map +1 -0
  314. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts +45 -0
  315. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -0
  316. package/dist/core/tokens/formatters/style-dictionary-v3.js +214 -0
  317. package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -0
  318. package/dist/core/tokens/formatters/tailwind-v3.d.ts +37 -0
  319. package/dist/core/tokens/formatters/tailwind-v3.d.ts.map +1 -0
  320. package/dist/core/tokens/formatters/tailwind-v3.js +238 -0
  321. package/dist/core/tokens/formatters/tailwind-v3.js.map +1 -0
  322. package/dist/core/tokens/formatters/tailwind-v4.d.ts +41 -0
  323. package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -0
  324. package/dist/core/tokens/formatters/tailwind-v4.js +336 -0
  325. package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -0
  326. package/dist/core/tokens/formatters/tokens-studio.d.ts +44 -0
  327. package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -0
  328. package/dist/core/tokens/formatters/tokens-studio.js +257 -0
  329. package/dist/core/tokens/formatters/tokens-studio.js.map +1 -0
  330. package/dist/core/tokens/formatters/ts-module.d.ts +35 -0
  331. package/dist/core/tokens/formatters/ts-module.d.ts.map +1 -0
  332. package/dist/core/tokens/formatters/ts-module.js +199 -0
  333. package/dist/core/tokens/formatters/ts-module.js.map +1 -0
  334. package/dist/core/tokens/index.d.ts +18 -0
  335. package/dist/core/tokens/index.d.ts.map +1 -0
  336. package/dist/core/tokens/index.js +17 -0
  337. package/dist/core/tokens/index.js.map +1 -0
  338. package/dist/core/tokens/parsers/css-vars.d.ts +3 -0
  339. package/dist/core/tokens/parsers/css-vars.d.ts.map +1 -0
  340. package/dist/core/tokens/parsers/css-vars.js +5 -0
  341. package/dist/core/tokens/parsers/css-vars.js.map +1 -0
  342. package/dist/core/tokens/parsers/dtcg.d.ts +21 -0
  343. package/dist/core/tokens/parsers/dtcg.d.ts.map +1 -0
  344. package/dist/core/tokens/parsers/dtcg.js +281 -0
  345. package/dist/core/tokens/parsers/dtcg.js.map +1 -0
  346. package/dist/core/tokens/parsers/index.d.ts +37 -0
  347. package/dist/core/tokens/parsers/index.d.ts.map +1 -0
  348. package/dist/core/tokens/parsers/index.js +139 -0
  349. package/dist/core/tokens/parsers/index.js.map +1 -0
  350. package/dist/core/tokens/parsers/json.d.ts +4 -0
  351. package/dist/core/tokens/parsers/json.d.ts.map +1 -0
  352. package/dist/core/tokens/parsers/json.js +8 -0
  353. package/dist/core/tokens/parsers/json.js.map +1 -0
  354. package/dist/core/tokens/parsers/scss.d.ts +3 -0
  355. package/dist/core/tokens/parsers/scss.d.ts.map +1 -0
  356. package/dist/core/tokens/parsers/scss.js +5 -0
  357. package/dist/core/tokens/parsers/scss.js.map +1 -0
  358. package/dist/core/tokens/parsers/stubs.d.ts +15 -0
  359. package/dist/core/tokens/parsers/stubs.d.ts.map +1 -0
  360. package/dist/core/tokens/parsers/stubs.js +21 -0
  361. package/dist/core/tokens/parsers/stubs.js.map +1 -0
  362. package/dist/core/tokens/parsers/style-dictionary-v3.d.ts +3 -0
  363. package/dist/core/tokens/parsers/style-dictionary-v3.d.ts.map +1 -0
  364. package/dist/core/tokens/parsers/style-dictionary-v3.js +5 -0
  365. package/dist/core/tokens/parsers/style-dictionary-v3.js.map +1 -0
  366. package/dist/core/tokens/parsers/tailwind-v3.d.ts +3 -0
  367. package/dist/core/tokens/parsers/tailwind-v3.d.ts.map +1 -0
  368. package/dist/core/tokens/parsers/tailwind-v3.js +5 -0
  369. package/dist/core/tokens/parsers/tailwind-v3.js.map +1 -0
  370. package/dist/core/tokens/parsers/tailwind-v4.d.ts +3 -0
  371. package/dist/core/tokens/parsers/tailwind-v4.d.ts.map +1 -0
  372. package/dist/core/tokens/parsers/tailwind-v4.js +5 -0
  373. package/dist/core/tokens/parsers/tailwind-v4.js.map +1 -0
  374. package/dist/core/tokens/parsers/tokens-studio.d.ts +3 -0
  375. package/dist/core/tokens/parsers/tokens-studio.d.ts.map +1 -0
  376. package/dist/core/tokens/parsers/tokens-studio.js +5 -0
  377. package/dist/core/tokens/parsers/tokens-studio.js.map +1 -0
  378. package/dist/core/tokens/schemas.d.ts +155 -0
  379. package/dist/core/tokens/schemas.d.ts.map +1 -0
  380. package/dist/core/tokens/schemas.js +153 -0
  381. package/dist/core/tokens/schemas.js.map +1 -0
  382. package/dist/core/tokens/transforms/color.d.ts +9 -0
  383. package/dist/core/tokens/transforms/color.d.ts.map +1 -0
  384. package/dist/core/tokens/transforms/color.js +13 -0
  385. package/dist/core/tokens/transforms/color.js.map +1 -0
  386. package/dist/core/tokens/transforms/index.d.ts +36 -0
  387. package/dist/core/tokens/transforms/index.d.ts.map +1 -0
  388. package/dist/core/tokens/transforms/index.js +30 -0
  389. package/dist/core/tokens/transforms/index.js.map +1 -0
  390. package/dist/core/tokens/transforms/size.d.ts +7 -0
  391. package/dist/core/tokens/transforms/size.d.ts.map +1 -0
  392. package/dist/core/tokens/transforms/size.js +8 -0
  393. package/dist/core/tokens/transforms/size.js.map +1 -0
  394. package/dist/core/tokens/types.d.ts +284 -0
  395. package/dist/core/tokens/types.d.ts.map +1 -0
  396. package/dist/core/tokens/types.js +19 -0
  397. package/dist/core/tokens/types.js.map +1 -0
  398. package/dist/core/tokens-tools.d.ts +285 -0
  399. package/dist/core/tokens-tools.d.ts.map +1 -0
  400. package/dist/core/tokens-tools.js +1789 -0
  401. package/dist/core/tokens-tools.js.map +1 -0
  402. package/dist/core/types/design-code.d.ts +271 -0
  403. package/dist/core/types/design-code.d.ts.map +1 -0
  404. package/dist/core/types/design-code.js +5 -0
  405. package/dist/core/types/design-code.js.map +1 -0
  406. package/dist/core/types/enriched.d.ts +213 -0
  407. package/dist/core/types/enriched.d.ts.map +1 -0
  408. package/dist/core/types/enriched.js +6 -0
  409. package/dist/core/types/enriched.js.map +1 -0
  410. package/dist/core/types/index.d.ts +104 -0
  411. package/dist/core/types/index.d.ts.map +1 -0
  412. package/dist/core/types/index.js +5 -0
  413. package/dist/core/types/index.js.map +1 -0
  414. package/dist/core/variable-resolver.d.ts +45 -0
  415. package/dist/core/variable-resolver.d.ts.map +1 -0
  416. package/dist/core/variable-resolver.js +86 -0
  417. package/dist/core/variable-resolver.js.map +1 -0
  418. package/dist/core/version-tools.d.ts +59 -0
  419. package/dist/core/version-tools.d.ts.map +1 -0
  420. package/dist/core/version-tools.js +1200 -0
  421. package/dist/core/version-tools.js.map +1 -0
  422. package/dist/core/websocket-connector.d.ts +247 -0
  423. package/dist/core/websocket-connector.d.ts.map +1 -0
  424. package/dist/core/websocket-connector.js +437 -0
  425. package/dist/core/websocket-connector.js.map +1 -0
  426. package/dist/core/websocket-server.d.ts +290 -0
  427. package/dist/core/websocket-server.d.ts.map +1 -0
  428. package/dist/core/websocket-server.js +957 -0
  429. package/dist/core/websocket-server.js.map +1 -0
  430. package/dist/core/write-tools.d.ts +7 -0
  431. package/dist/core/write-tools.d.ts.map +1 -0
  432. package/dist/core/write-tools.js +2507 -0
  433. package/dist/core/write-tools.js.map +1 -0
  434. package/dist/local.d.ts +105 -0
  435. package/dist/local.d.ts.map +1 -0
  436. package/dist/local.js +3314 -0
  437. package/dist/local.js.map +1 -0
  438. package/figma-desktop-bridge/README.md +365 -0
  439. package/figma-desktop-bridge/code.js +7359 -0
  440. package/figma-desktop-bridge/icon.png +0 -0
  441. package/figma-desktop-bridge/manifest.json +67 -0
  442. package/figma-desktop-bridge/ui.html +2783 -0
  443. package/package.json +103 -0
@@ -0,0 +1,1788 @@
1
+ /**
2
+ * MCP tool registrar for figma_export_tokens and figma_import_tokens.
3
+ *
4
+ * Current scope (v1.27.0):
5
+ * - figma_export_tokens: working for DTCG JSON (canonical) and CSS
6
+ * custom properties output. Other formats (Tailwind v4, SCSS, TS
7
+ * module, Tokens Studio, Style Dictionary v3) are scaffolded and
8
+ * return TokenFormatNotImplementedError with a helpful message
9
+ * directing users to DTCG.
10
+ * - figma_import_tokens: working for DTCG JSON input with full
11
+ * diff-aware merge and a complete apply phase:
12
+ * • toUpdate — value updates batched via the plugin bridge,
13
+ * including alias-target updates ({ type: "VARIABLE_ALIAS", id })
14
+ * when the reference resolves to an existing or just-created
15
+ * variable.
16
+ * • toCreate — missing collections (created with the token file's
17
+ * full mode list) and missing variables in one batched script;
18
+ * literal values first, alias values in a second pass so
19
+ * within-batch alias targets exist. TIMING/EASING cannot be
20
+ * created via the Plugin API and are skipped with a warning.
21
+ * • toDelete — STRICTLY gated behind strategy: "replace". Merge
22
+ * (default) preserves Figma-only variables and only reports them.
23
+ *
24
+ * Both tools auto-discover `tokens.config.json` at the project root and use
25
+ * its source/generated/modes/conflictResolution settings as defaults. They
26
+ * stay zero-arg in normal use.
27
+ */
28
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, } from "node:fs";
29
+ import { dirname, isAbsolute, join, resolve } from "node:path";
30
+ import { createChildLogger } from "./logger.js";
31
+ import { buildTokenLookup, canonicalizeTokenValueForComparison, clamp01, convertFigmaVariablesToDocument, ExportTokensInputSchema, ImportTokensInputSchema, format as formatTokenDocument, hexToRawRgba, loadTokensConfig, parse as parseTokenPayload, resolveOutputTargets, stripRawColorFromValues, } from "./tokens/index.js";
32
+ const logger = createChildLogger({ component: "tokens-tools" });
33
+ /**
34
+ * MCP version stamp embedded in DTCG `$extensions["figma-console-mcp"].mcpVersion`
35
+ * on every exported token document. Kept in sync with package.json by
36
+ * scripts/release.sh — see step 3 of the release flow.
37
+ */
38
+ const MCP_VERSION = "1.35.0";
39
+ const EXPORT_TOOL_DESCRIPTION = `Export Figma variables to design token files in your codebase. Bidirectional with figma_import_tokens — together they replace Style Dictionary and Tokens Studio's export pipeline for the popular styling methods.
40
+
41
+ FULLY-IMPLEMENTED OUTPUT FORMATS:
42
+ • dtcg — W3C DTCG JSON. Canonical pivot format. Round-trip safe via \`$extensions["figma-console-mcp"]\`.
43
+ • css-vars — CSS custom properties with mode-aware selectors (\`:root\`, \`.dark\`, \`[data-theme=...]\`).
44
+ • tailwind-v4 — Tailwind v4 \`@theme inline\` block. Token-to-namespace mapping (color/*, spacing/*, radius/*, etc.) generates Tailwind utility classes.
45
+ • tailwind-v3 — \`tailwind.config.js\` theme.extend object grouped under Tailwind's theme keys (colors, spacing, fontFamily, etc.).
46
+ • scss — \`$var: value;\` declarations. Multi-mode emits a primary variable + a mode-keyed SCSS map for runtime access.
47
+ • ts-module — \`export const tokens = { ... } as const\` with derived \`Tokens\` type. Multi-mode tokens emit as \`{ Light: ..., Dark: ... }\` objects.
48
+ • json-flat — flat key-value JSON (\`{"ds-color-primary": "#4085F2"}\`) for custom build scripts.
49
+ • json-nested — nested object JSON mirroring the token path tree.
50
+ • style-dictionary-v3 — SD v3 source format with bare \`value\`/\`type\` keys (back-compat for existing SD users).
51
+ • tokens-studio — Tokens Studio multi-file layout (\`$themes.json\` + \`$metadata.json\` + per-set files). Preserves Figma collection/mode bindings for round-trip with the TS plugin.
52
+
53
+ ZERO-ARG USAGE: With a tokens.config.json at your project root, just call the tool with no args — it picks up source dir, output formats, modes, prefix, etc. from config. See the response's \`suggestedScaffold\` payload when no config is detected — present it to the user, write the scaffold via your file tools, then call again.
54
+
55
+ MERGE STRATEGY: Default \`strategy: "merge"\` only writes tokens that actually changed in Figma since the last sync. Use \`dry-run\` to preview what would change. Use \`replace\` to wipe and rewrite (rare; for resetting drift).
56
+
57
+ DTCG DIALECT (\`dtcgDialect\`, applies to dtcg/json-flat/json-nested outputs): legacy (default): hex-string colors, maximum compatibility (Style Dictionary v4, Tokens Studio). 2025: DTCG 2025.10 object colors/dimensions (Style Dictionary v5+). figma_import_tokens accepts BOTH dialects regardless of this setting.
58
+
59
+ ROUND-TRIP SAFETY: Figma variable IDs are preserved in DTCG \`$extensions["figma-console-mcp"]\` so renames on either side don't create duplicates. The same metadata enables non-destructive incremental sync via figma_import_tokens. Variable scopes (when non-default) and per-platform codeSyntax (when set) are stashed there too and round-trip through import.`;
60
+ const IMPORT_TOOL_DESCRIPTION = `Push design tokens from your codebase into Figma as variables. Bidirectional with figma_export_tokens.
61
+
62
+ ACCEPTS: DTCG JSON (canonical, fully supported including round-trip metadata preservation). Tokens Studio JSON, CSS custom properties, Tailwind v4 @theme, SCSS, and Style Dictionary v3 are scaffolded but return a NotImplementedError — convert to DTCG first via figma_export_tokens or hand-author DTCG. Use \`format: "auto"\` to sniff the input.
63
+
64
+ APPLY PHASE (full bidirectional sync): toCreate entries create missing collections (with the token file's full mode list) and missing variables in one batched plugin round-trip — literal values first, alias values in a second pass so aliases between newly-created variables resolve. toUpdate entries push value updates in a batched round-trip, INCLUDING alias-target updates: when a token's value is a reference, it's written as a Figma variable alias if the reference resolves to an existing or just-created variable (unresolvable references skip with a warning). toDelete entries are STRICTLY gated behind \`strategy: "replace"\` — in replace mode, Figma variables absent from the token file are permanently deleted (a loud warning lists the count); merge (default) preserves them and only reports. TIMING/EASING variables cannot be created or written via the Plugin API and are skipped with a warning. Variable scopes and per-platform codeSyntax (from \`$extensions["figma-console-mcp"].scopes/.codeSyntax\`) are diffed and applied too — absent fields mean "no opinion" (Figma-side metadata is preserved), an explicit value is authoritative. Partial-success semantics: per-item errors surface in applyResult.errors[] without failing the batch.
65
+
66
+ DIFF-AWARE: Default \`strategy: "merge"\` diffs against current Figma state and applies only deltas. The hacked-color scenario — designer edits one hex value in their CSS — produces exactly one Figma API update, not a full collection rewrite. Match priority: Figma variable ID (in \`$extensions["figma-console-mcp"].variableId\`), then exact token path, then value fingerprint.
67
+
68
+ CONFLICT HANDLING: When BOTH Figma and code changed the same token since the last sync, \`onConflict: "ask"\` (default) surfaces the conflict and writes nothing. Use \`figma-wins\` / \`code-wins\` to auto-resolve, or \`skip\` to leave conflicts alone and proceed with the rest.
69
+
70
+ DRY-RUN: Default first call after detecting changes is dry-run for safety. The response includes the full diff plan; user confirms, then call again with \`dryRun: false\` (or \`strategy\` other than dry-run) to apply.`;
71
+ export function registerExportTokensTool(server, getDesktopConnector, opts = {}) {
72
+ server.tool("figma_export_tokens", EXPORT_TOOL_DESCRIPTION, ExportTokensInputSchema.shape, async (args) => {
73
+ try {
74
+ return await handleExport(args, getDesktopConnector, opts);
75
+ }
76
+ catch (err) {
77
+ logger.error({ err }, "figma_export_tokens failed");
78
+ return {
79
+ content: [
80
+ {
81
+ type: "text",
82
+ text: JSON.stringify({
83
+ error: err instanceof Error ? err.message : String(err),
84
+ hint: "If this is a TokenFormatNotImplementedError for a non-DTCG/non-CSS format, export to 'dtcg' or 'css-vars' instead — those are the fully-implemented formats. The canonical DTCG JSON can be consumed by Style Dictionary v4 or any other DTCG-aware tooling.",
85
+ }),
86
+ },
87
+ ],
88
+ isError: true,
89
+ };
90
+ }
91
+ });
92
+ }
93
+ export function registerImportTokensTool(server, getDesktopConnector, opts = {}) {
94
+ server.tool("figma_import_tokens", IMPORT_TOOL_DESCRIPTION, ImportTokensInputSchema._def.schema.shape, async (args) => {
95
+ try {
96
+ return await handleImport(args, getDesktopConnector, opts);
97
+ }
98
+ catch (err) {
99
+ logger.error({ err }, "figma_import_tokens failed");
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: JSON.stringify({
105
+ error: err instanceof Error ? err.message : String(err),
106
+ hint: "If this is a NotImplementedError for a non-DTCG format, convert the source to DTCG first (e.g. via figma_export_tokens then edit the JSON).",
107
+ }),
108
+ },
109
+ ],
110
+ isError: true,
111
+ };
112
+ }
113
+ });
114
+ }
115
+ /**
116
+ * Convenience: register both tools at once.
117
+ */
118
+ export function registerTokensTools(server, getDesktopConnector, opts = {}) {
119
+ registerExportTokensTool(server, getDesktopConnector, opts);
120
+ registerImportTokensTool(server, getDesktopConnector, opts);
121
+ }
122
+ /**
123
+ * Standardized error for fs-dependent paths called in Cloud Mode.
124
+ */
125
+ function cloudModeFsError(operation) {
126
+ return new Error(`[figma-console-mcp] ${operation} is a Local Mode operation — Cloud Mode (Cloudflare Workers) has no local filesystem access. ` +
127
+ "Use one of these alternatives:\n" +
128
+ " • Export: omit `configPath` and `outputPath` — the tool will return token content inline in the response. Have your AI client write the files via its own Edit/Write tools.\n" +
129
+ " • Import: pass token data inline via the `payload` argument (single file) or `files` argument (multi-file). Omit `configPath`.\n" +
130
+ "For full filesystem support (tokens.config.json autodiscovery, automatic writes to source/generated dirs), run the MCP in Local Mode via NPX.");
131
+ }
132
+ // ============================================================================
133
+ // HANDLERS
134
+ // ============================================================================
135
+ async function handleExport(args, getDesktopConnector, opts) {
136
+ // Cloud Mode guard. Any filesystem operation needs to bail with a clear
137
+ // message before the fs call actually throws something cryptic.
138
+ if (opts.isRemoteMode) {
139
+ if (args.configPath) {
140
+ throw cloudModeFsError("`configPath` (tokens.config.json autodiscovery)");
141
+ }
142
+ if (args.outputPath) {
143
+ throw cloudModeFsError("`outputPath` (writing token files to disk)");
144
+ }
145
+ }
146
+ // 1. Load config (autodiscover or explicit). Skip entirely in Cloud Mode
147
+ // — tokens.config.json lookup requires a filesystem.
148
+ const loaded = opts.isRemoteMode
149
+ ? null
150
+ : loadTokensConfig({ explicitPath: args.configPath });
151
+ // 2. Fetch variables from Figma via the desktop connector. The connector's
152
+ // getVariablesFromPluginUI returns the plugin's cached variable data
153
+ // (instant, all plans, full Plugin API fidelity).
154
+ const connector = await getDesktopConnector();
155
+ // We don't have a specific fileKey here unless the caller passed one in
156
+ // config; pass undefined to let the connector use the currently-connected
157
+ // file context.
158
+ const fileKey = loaded?.config?.figmaFile
159
+ ? extractFileKey(loaded.config.figmaFile)
160
+ : undefined;
161
+ const raw = await connector.getVariablesFromPluginUI(fileKey);
162
+ // Unwrap the plugin's response — same logic as the existing figma_get_variables.
163
+ const variableData = raw?.result?.variables ? raw.result : raw;
164
+ if (!variableData?.variables) {
165
+ throw new Error("[figma-console-mcp] No variables found in the connected Figma file. Make sure the Desktop Bridge plugin is running and the file has at least one variable collection.");
166
+ }
167
+ // 3. Normalize to the converter's expected shape.
168
+ const payload = normalizeFigmaPayload(variableData);
169
+ // 4. Convert to canonical TokenDocument.
170
+ const { document, warnings } = convertFigmaVariablesToDocument(payload, {
171
+ figmaFileKey: fileKey,
172
+ collectionIds: args.collectionIds,
173
+ modes: args.modes,
174
+ stripPrefix: args.prefix,
175
+ mcpVersion: MCP_VERSION,
176
+ });
177
+ // 5. Resolve which output formats to emit.
178
+ const targets = resolveOutputTargets(loaded?.config ?? null, args.format);
179
+ // 6. Format the document for each target.
180
+ const allFiles = [];
181
+ const allWarnings = [...warnings];
182
+ for (const target of targets) {
183
+ try {
184
+ const result = formatTokenDocument(document, {
185
+ target: {
186
+ ...target,
187
+ splitByMode: args.splitByMode ?? target.splitByMode,
188
+ splitByCollection: args.splitByCollection ?? target.splitByCollection,
189
+ prefix: args.prefix ?? target.prefix,
190
+ resolveAliases: args.resolveAliases ?? target.resolveAliases,
191
+ dtcgDialect: args.dtcgDialect ?? target.dtcgDialect,
192
+ transforms: {
193
+ colorFormat: args.colorFormat ?? target.transforms?.colorFormat,
194
+ sizeUnit: args.sizeUnit ?? target.transforms?.sizeUnit,
195
+ remBase: args.remBase ?? target.transforms?.remBase,
196
+ },
197
+ },
198
+ projectRoot: loaded?.projectRoot,
199
+ });
200
+ for (const file of result.files) {
201
+ allFiles.push({ format: target.format, ...file });
202
+ }
203
+ allWarnings.push(...result.warnings);
204
+ }
205
+ catch (err) {
206
+ // Non-DTCG formatters throw NotImplementedError. Surface it without
207
+ // bailing on the other targets.
208
+ allWarnings.push(`[${target.format}] ${err instanceof Error ? err.message : String(err)}`);
209
+ }
210
+ }
211
+ // 7. If outputPath is set, write to disk. Otherwise return inline.
212
+ // Output routing: canonical-format files (matching config.source.canonical)
213
+ // go to source.dir; everything else goes to generated.dir.
214
+ const dryRun = args.strategy === "dry-run";
215
+ const writtenPaths = [];
216
+ if (!dryRun) {
217
+ for (const file of allFiles) {
218
+ const base = resolveOutputBaseForFormat(args.outputPath, loaded, file.format);
219
+ if (!base)
220
+ continue; // No config or outputPath → caller will get content inline.
221
+ const fullPath = isAbsolute(file.path)
222
+ ? file.path
223
+ : join(base, file.path);
224
+ mkdirSync(dirname(fullPath), { recursive: true });
225
+ writeFileSync(fullPath, file.content, "utf-8");
226
+ writtenPaths.push(fullPath);
227
+ }
228
+ }
229
+ const outputBase = writtenPaths.length > 0 ? "(multiple)" : null;
230
+ return {
231
+ content: [
232
+ {
233
+ type: "text",
234
+ text: JSON.stringify({
235
+ success: true,
236
+ mode: dryRun ? "dry-run" : outputBase ? "written" : "inline",
237
+ configFound: !!loaded,
238
+ configPath: loaded?.configPath ?? null,
239
+ collections: document.sets.map((s) => ({
240
+ name: s.name,
241
+ modes: s.modes,
242
+ tokenCount: s.tokens.length,
243
+ figmaCollectionId: s.meta?.figmaCollectionId,
244
+ })),
245
+ outputs: dryRun
246
+ ? allFiles.map((f) => ({
247
+ format: f.format,
248
+ path: f.path,
249
+ preview: f.content.slice(0, 500) + (f.content.length > 500 ? "…" : ""),
250
+ }))
251
+ : outputBase
252
+ ? writtenPaths.map((p) => ({ writtenTo: p }))
253
+ : allFiles,
254
+ warnings: allWarnings,
255
+ ...(loaded
256
+ ? {}
257
+ : {
258
+ suggestedScaffold: {
259
+ note: "No tokens.config.json detected. Recommended scaffold:",
260
+ configContent: JSON.stringify({
261
+ $schema: "https://figma-console-mcp.southleft.com/schemas/tokens.config.v1.json",
262
+ source: { dir: "src/styles/tokens", canonical: "dtcg" },
263
+ generated: {
264
+ dir: "src/styles/generated",
265
+ formats: [{ format: "css-vars", splitByMode: true }],
266
+ },
267
+ conflictResolution: "ask",
268
+ }, null, 2),
269
+ directories: ["src/styles/tokens", "src/styles/generated"],
270
+ nextSteps: "Write tokens.config.json at the project root, create the directories, then call figma_export_tokens again — zero args needed.",
271
+ },
272
+ }),
273
+ }, null, 2),
274
+ },
275
+ ],
276
+ };
277
+ }
278
+ async function handleImport(args, getDesktopConnector, opts) {
279
+ // Cloud Mode guard: filesystem operations are unavailable. Inline
280
+ // `payload` / `files` arguments still work and are the supported path.
281
+ if (opts.isRemoteMode) {
282
+ if (args.configPath) {
283
+ throw cloudModeFsError("`configPath` (tokens.config.json autodiscovery)");
284
+ }
285
+ if (!args.payload && !args.files) {
286
+ throw cloudModeFsError("Implicit source-dir reads (when neither `payload` nor `files` is provided)");
287
+ }
288
+ }
289
+ // 1. Load config + resolve where the source payload(s) live.
290
+ const loaded = opts.isRemoteMode
291
+ ? null
292
+ : loadTokensConfig({ explicitPath: args.configPath });
293
+ // 2. Collect input payloads.
294
+ const inputFiles = collectInputFiles(args, loaded);
295
+ // 3. Parse each input file to a TokenDocument.
296
+ const documents = [];
297
+ const parseWarnings = [];
298
+ for (const file of inputFiles) {
299
+ const parseResult = parseTokenPayload(args.format ?? "auto", {
300
+ payload: file.content,
301
+ sourcePath: file.path,
302
+ });
303
+ documents.push(parseResult.document);
304
+ parseWarnings.push(...parseResult.warnings);
305
+ }
306
+ // 4. Merge documents into a single TokenDocument (sets are concatenated;
307
+ // tokens within sets are combined by path).
308
+ const merged = mergeDocuments(documents);
309
+ // 5. Fetch current Figma state for diffing.
310
+ const connector = await getDesktopConnector();
311
+ const fileKey = loaded?.config?.figmaFile
312
+ ? extractFileKey(loaded.config.figmaFile)
313
+ : undefined;
314
+ const raw = await connector.getVariablesFromPluginUI(fileKey);
315
+ const variableData = raw?.result?.variables ? raw.result : raw;
316
+ const figmaPayload = normalizeFigmaPayload(variableData ?? { variables: [], variableCollections: [] });
317
+ const { document: figmaDoc } = convertFigmaVariablesToDocument(figmaPayload, {
318
+ figmaFileKey: fileKey,
319
+ mcpVersion: MCP_VERSION,
320
+ });
321
+ // 6. Compute the diff plan.
322
+ const diff = computeDiffPlan(figmaDoc, merged);
323
+ const dryRun = args.dryRun === true || args.strategy === "dry-run";
324
+ const strategy = args.strategy === "replace" ? "replace" : "merge";
325
+ // 7. Apply phase: when not dry-run, mutate Figma via the plugin bridge in
326
+ // three ordered sub-phases. Order matters: creates run FIRST so that
327
+ // alias-target updates in the update phase can point at just-created
328
+ // variables; deletes run LAST (and only under strategy "replace").
329
+ let applyResult = null;
330
+ let deleteWarning;
331
+ if (!dryRun) {
332
+ const acc = {
333
+ applied: 0,
334
+ created: 0,
335
+ createdCollections: 0,
336
+ renamed: 0,
337
+ deleted: 0,
338
+ failed: 0,
339
+ errors: [],
340
+ };
341
+ let anyPhaseRan = false;
342
+ const collectionModeMap = buildCollectionModeMap(figmaPayload);
343
+ const toCreateKeys = new Set(diff.toCreate.map((e) => e.path));
344
+ // Mutable map the alias resolver closes over — populated by the create
345
+ // phase so later alias resolutions see freshly-created variable IDs.
346
+ const createdIdByKey = new Map();
347
+ const resolveAliasId = makeAliasIdResolver(figmaDoc, merged, toCreateKeys, createdIdByKey);
348
+ // 7a. CREATE phase — missing collections (with the token file's full
349
+ // mode list) and missing variables. Literal values are set at
350
+ // creation; alias values apply in a second in-script pass after ALL
351
+ // variables exist, so aliases among created variables resolve.
352
+ if (diff.toCreate.length > 0) {
353
+ const createPlan = buildCreatePlan(diff.toCreate, merged, figmaPayload, resolveAliasId, parseWarnings);
354
+ if (createPlan.newCollections.length > 0 ||
355
+ createPlan.existingCollections.length > 0) {
356
+ anyPhaseRan = true;
357
+ const createResult = await applyCreates(connector, createPlan);
358
+ acc.created += createResult.created;
359
+ acc.createdCollections += createResult.createdCollections;
360
+ acc.failed += createResult.failed;
361
+ acc.errors.push(...createResult.errors);
362
+ for (const [key, id] of createResult.createdIdByKey) {
363
+ createdIdByKey.set(key, id);
364
+ }
365
+ }
366
+ }
367
+ // 7b. UPDATE phase — value updates including alias-target updates
368
+ // (references resolved to { type: "VARIABLE_ALIAS", id }) AND
369
+ // renames (ID-matched tokens whose path moved: variable.name is set
370
+ // to the new '/'-joined path instead of create+delete).
371
+ if (diff.toUpdate.length > 0 || diff.toRename.length > 0) {
372
+ const renameEntries = diff.toRename.map((r) => ({
373
+ path: r.from, // Figma-side lookup (old key)
374
+ codePath: r.path, // code-side lookup (new key)
375
+ newName: r.newName,
376
+ before: undefined,
377
+ after: undefined,
378
+ changes: r.changes,
379
+ }));
380
+ const updates = buildUpdatePayloads([...renameEntries, ...diff.toUpdate], figmaDoc, merged, collectionModeMap, parseWarnings, resolveAliasId);
381
+ if (updates.length > 0) {
382
+ anyPhaseRan = true;
383
+ const renameIds = new Set(updates.filter((u) => u.newName !== undefined).map((u) => u.variableId));
384
+ const updateResult = await applyUpdates(connector, updates);
385
+ const renamedFailed = updateResult.errors.filter((e) => renameIds.has(e.variableId)).length;
386
+ const renamedOk = renameIds.size - renamedFailed;
387
+ acc.renamed += renamedOk;
388
+ // `applied` counts successful non-rename update entries; a rename
389
+ // entry (even one that also carried value changes) counts once,
390
+ // under `renamed`.
391
+ acc.applied += updateResult.applied - renamedOk;
392
+ acc.failed += updateResult.failed;
393
+ acc.errors.push(...updateResult.errors);
394
+ }
395
+ }
396
+ // 7c. DELETE phase — STRICTLY gated behind strategy "replace". Merge
397
+ // (the default) never deletes; it only reports Figma-only tokens.
398
+ if (strategy === "replace" && diff.toDelete.length > 0) {
399
+ anyPhaseRan = true;
400
+ const deleteResult = await applyDeletes(connector, diff.toDelete, figmaDoc);
401
+ acc.deleted += deleteResult.deleted;
402
+ acc.failed += deleteResult.failed;
403
+ acc.errors.push(...deleteResult.errors);
404
+ if (deleteResult.deleted > 0) {
405
+ deleteWarning = `⚠️ REPLACE STRATEGY: permanently deleted ${deleteResult.deleted} Figma variable(s) that were not present in the token file. Recover via Figma's version history / Edit > Undo if this was unintended.`;
406
+ parseWarnings.push(deleteWarning);
407
+ }
408
+ }
409
+ if (anyPhaseRan)
410
+ applyResult = acc;
411
+ }
412
+ // Slim the diff for the response: full entries blow past LLM context for
413
+ // large design systems. Show counts + a sample of first N entries from
414
+ // each bucket; the caller can re-run with format=detailed if they want
415
+ // everything.
416
+ const SAMPLE_LIMIT = 20;
417
+ const slimDiff = {
418
+ summary: {
419
+ toCreate: diff.toCreate.length,
420
+ toUpdate: diff.toUpdate.length,
421
+ toRename: diff.toRename.length,
422
+ toDelete: diff.toDelete.length,
423
+ unchanged: diff.unchanged,
424
+ },
425
+ samples: {
426
+ toCreate: diff.toCreate.slice(0, SAMPLE_LIMIT).map((e) => ({
427
+ path: e.path,
428
+ type: e.type,
429
+ })),
430
+ toUpdate: diff.toUpdate.slice(0, SAMPLE_LIMIT),
431
+ toRename: diff.toRename.slice(0, SAMPLE_LIMIT).map((e) => ({
432
+ from: e.from,
433
+ to: e.path,
434
+ variableId: e.variableId,
435
+ })),
436
+ toDelete: diff.toDelete.slice(0, SAMPLE_LIMIT),
437
+ },
438
+ truncated: {
439
+ toCreate: diff.toCreate.length > SAMPLE_LIMIT,
440
+ toUpdate: diff.toUpdate.length > SAMPLE_LIMIT,
441
+ toRename: diff.toRename.length > SAMPLE_LIMIT,
442
+ toDelete: diff.toDelete.length > SAMPLE_LIMIT,
443
+ },
444
+ };
445
+ return {
446
+ content: [
447
+ {
448
+ type: "text",
449
+ text: JSON.stringify({
450
+ success: true,
451
+ mode: dryRun ? "dry-run" : applyResult ? "applied" : "no-changes",
452
+ applyNote: dryRun
453
+ ? "Dry-run only — no Figma mutations performed."
454
+ : applyResult
455
+ ? [
456
+ applyResult.createdCollections > 0
457
+ ? `Created ${applyResult.createdCollections} collection(s).`
458
+ : null,
459
+ applyResult.created > 0
460
+ ? `Created ${applyResult.created} variable(s).`
461
+ : null,
462
+ applyResult.renamed > 0
463
+ ? `Renamed ${applyResult.renamed} variable(s).`
464
+ : null,
465
+ `Applied ${applyResult.applied} value update(s).`,
466
+ applyResult.deleted > 0
467
+ ? `Deleted ${applyResult.deleted} variable(s) (replace strategy).`
468
+ : null,
469
+ `${applyResult.failed} failed.`,
470
+ ]
471
+ .filter(Boolean)
472
+ .join(" ")
473
+ : diff.toUpdate.length === 0 &&
474
+ diff.toCreate.length === 0 &&
475
+ diff.toRename.length === 0
476
+ ? "Nothing to apply — all tokens already in sync."
477
+ : "Changes were detected but skipped (likely all unresolvable aliases or non-writable TIMING/EASING types — see warnings).",
478
+ deleteNote: deleteWarning
479
+ ? deleteWarning
480
+ : diff.toDelete.length > 0
481
+ ? strategy === "replace" && dryRun
482
+ ? `${diff.toDelete.length} Figma-only token(s) would be PERMANENTLY DELETED on apply (replace strategy + dry-run).`
483
+ : strategy === "replace"
484
+ ? `${diff.toDelete.length} Figma-only token(s) targeted for deletion (replace strategy) — see applyResult for outcome.`
485
+ : `${diff.toDelete.length} Figma-only token(s) preserved (merge strategy). Use strategy: "replace" to delete them, or figma_delete_variable manually.`
486
+ : undefined,
487
+ inputFileCount: inputFiles.length,
488
+ parsedSetCount: merged.sets.length,
489
+ parsedTokenCount: merged.sets.reduce((n, s) => n + s.tokens.length, 0),
490
+ diff: slimDiff,
491
+ applyResult: applyResult
492
+ ? {
493
+ applied: applyResult.applied,
494
+ created: applyResult.created,
495
+ createdCollections: applyResult.createdCollections,
496
+ renamed: applyResult.renamed,
497
+ deleted: applyResult.deleted,
498
+ failed: applyResult.failed,
499
+ errors: applyResult.errors.slice(0, 10),
500
+ }
501
+ : null,
502
+ warnings: parseWarnings,
503
+ }, null, 2),
504
+ },
505
+ ],
506
+ };
507
+ }
508
+ // ============================================================================
509
+ // HELPERS
510
+ // ============================================================================
511
+ /**
512
+ * Extract a Figma file key from a URL or return the string as-is if it's
513
+ * already a key.
514
+ */
515
+ function extractFileKey(figmaFileOrUrl) {
516
+ const match = figmaFileOrUrl.match(/figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)/);
517
+ return match ? match[1] : figmaFileOrUrl;
518
+ }
519
+ /**
520
+ * Normalize the plugin's variable response into the converter's expected
521
+ * shape. The plugin may return collections keyed by ID or as an array;
522
+ * normalize both into an array.
523
+ */
524
+ function normalizeFigmaPayload(raw) {
525
+ const collections = Array.isArray(raw.variableCollections)
526
+ ? raw.variableCollections
527
+ : Object.entries(raw.variableCollections ?? {}).map(([id, c]) => ({ id, ...c }));
528
+ const variables = Array.isArray(raw.variables)
529
+ ? raw.variables
530
+ : Object.entries(raw.variables ?? {}).map(([id, v]) => ({
531
+ id,
532
+ ...v,
533
+ }));
534
+ return { collections, variables };
535
+ }
536
+ /**
537
+ * Resolve where to write output files for a specific format. Canonical formats
538
+ * (matching config.source.canonical) go to source.dir; everything else goes
539
+ * to generated.dir. Caller-supplied outputPath wins over both.
540
+ */
541
+ function resolveOutputBaseForFormat(outputPath, loaded, format) {
542
+ // Explicit outputPath always wins.
543
+ if (outputPath) {
544
+ return isAbsolute(outputPath)
545
+ ? outputPath
546
+ : resolve(loaded?.projectRoot ?? process.cwd(), outputPath);
547
+ }
548
+ if (!loaded)
549
+ return null;
550
+ // Canonical format goes to source.dir.
551
+ if (format === loaded.config.source.canonical) {
552
+ return resolve(loaded.projectRoot, loaded.config.source.dir);
553
+ }
554
+ // Otherwise generated.dir.
555
+ if (loaded.config.generated?.dir) {
556
+ return resolve(loaded.projectRoot, loaded.config.generated.dir);
557
+ }
558
+ return null;
559
+ }
560
+ /**
561
+ * Gather input files for import. Priority:
562
+ * 1. Explicit payload string.
563
+ * 2. Explicit files array.
564
+ * 3. Config-derived source dir (read every *.tokens.json file).
565
+ */
566
+ function collectInputFiles(args, loaded) {
567
+ if (args.payload) {
568
+ return [{ path: "<inline>", content: args.payload }];
569
+ }
570
+ if (args.files?.length) {
571
+ return args.files;
572
+ }
573
+ if (!loaded) {
574
+ throw new Error("[figma-console-mcp] No payload, files, or tokens.config.json supplied. Pass one of: { payload }, { files }, or have tokens.config.json at the project root.");
575
+ }
576
+ // Walk the source dir for *.tokens.json files. Currently a flat scan;
577
+ // the config's source.pattern is honored as a simple glob (just suffix
578
+ // matching for now).
579
+ const sourceDir = resolve(loaded.projectRoot, loaded.config.source.dir);
580
+ if (!existsSync(sourceDir)) {
581
+ throw new Error(`[figma-console-mcp] Source dir does not exist: ${sourceDir}. Make sure tokens.config.json's source.dir points at a directory that exists.`);
582
+ }
583
+ const pattern = loaded.config.source.pattern ?? "*.tokens.json";
584
+ const suffix = pattern.replace(/^\*/, "");
585
+ const entries = readdirSync(sourceDir);
586
+ return entries
587
+ .filter((e) => e.endsWith(suffix))
588
+ .map((name) => {
589
+ const full = join(sourceDir, name);
590
+ return { path: full, content: readFileSync(full, "utf-8") };
591
+ });
592
+ }
593
+ /**
594
+ * Merge multiple TokenDocuments. Sets with the same name combine their
595
+ * tokens; tokens with the same path within a set have their mode-values
596
+ * merged (so splitByMode files reassemble cleanly into one multi-mode
597
+ * representation). Modes are unioned. Document-level metadata uses the
598
+ * first document's values.
599
+ */
600
+ function mergeDocuments(docs) {
601
+ if (docs.length === 0) {
602
+ return { sets: [], meta: {} };
603
+ }
604
+ if (docs.length === 1)
605
+ return docs[0];
606
+ const setsByName = new Map();
607
+ for (const doc of docs) {
608
+ for (const set of doc.sets) {
609
+ const existing = setsByName.get(set.name);
610
+ if (!existing) {
611
+ setsByName.set(set.name, { ...set, tokens: [...set.tokens] });
612
+ continue;
613
+ }
614
+ existing.modes = [...new Set([...existing.modes, ...set.modes])];
615
+ // Dedupe by path: tokens with the same path merge their values.
616
+ // Critical for splitByMode output where each file has the same tokens
617
+ // with a different mode's value, and the import needs to reassemble
618
+ // them into one multi-mode token instead of triplicating.
619
+ const byPath = new Map(existing.tokens.map((t) => [t.path.join("/"), t]));
620
+ for (const incoming of set.tokens) {
621
+ const key = incoming.path.join("/");
622
+ const found = byPath.get(key);
623
+ if (found) {
624
+ found.values = { ...found.values, ...incoming.values };
625
+ // Merge MCP extensions too — newer lastSyncedAt wins, lastSyncedValue
626
+ // unions across modes.
627
+ const aExt = found.extensions?.["figma-console-mcp"];
628
+ const bExt = incoming.extensions?.["figma-console-mcp"];
629
+ if (aExt || bExt) {
630
+ const merged = { ...(aExt ?? {}), ...(bExt ?? {}) };
631
+ if (aExt?.lastSyncedValue || bExt?.lastSyncedValue) {
632
+ merged.lastSyncedValue = {
633
+ ...(aExt?.lastSyncedValue ?? {}),
634
+ ...(bExt?.lastSyncedValue ?? {}),
635
+ };
636
+ }
637
+ found.extensions = { ...(found.extensions ?? {}), "figma-console-mcp": merged };
638
+ }
639
+ }
640
+ else {
641
+ existing.tokens.push(incoming);
642
+ byPath.set(key, incoming);
643
+ }
644
+ }
645
+ }
646
+ }
647
+ return {
648
+ $schema: docs[0].$schema,
649
+ sets: [...setsByName.values()],
650
+ meta: docs[0].meta,
651
+ };
652
+ }
653
+ /**
654
+ * Compute a diff plan between Figma's current state (left) and the code's
655
+ * proposed state (right). Returns a structured diff plan; value-update
656
+ * mutations are applied via the plugin bridge below.
657
+ *
658
+ * MATCH PRIORITY (as the tool description promises): Figma variable ID
659
+ * first, then exact set::path. A code-side token whose path has no Figma
660
+ * counterpart but whose $extensions["figma-console-mcp"].variableId matches
661
+ * a live variable is a RENAME — routed to toRename (name change + any
662
+ * value/meta changes), and its Figma-side counterpart is EXCLUDED from
663
+ * toDelete. Without this, a path rename in the code file would create a
664
+ * duplicate variable under merge, and under replace would permanently
665
+ * delete the original (detaching all its bindings).
666
+ *
667
+ * Exported for test coverage of the dialect-normalized comparison
668
+ * (round-trip exports in both DTCG dialects must diff as unchanged) and
669
+ * rename classification.
670
+ */
671
+ export function computeDiffPlan(figmaDoc, codeDoc) {
672
+ // Build lookup maps by path for both sides.
673
+ const figmaTokens = new Map();
674
+ for (const set of figmaDoc.sets) {
675
+ for (const t of set.tokens) {
676
+ figmaTokens.set(`${set.name}::${t.path.join(".")}`, t);
677
+ }
678
+ }
679
+ const codeTokens = new Map();
680
+ for (const set of codeDoc.sets) {
681
+ for (const t of set.tokens) {
682
+ codeTokens.set(`${set.name}::${t.path.join(".")}`, t);
683
+ }
684
+ }
685
+ // ID-first index: live Figma variableId → its diff key + token.
686
+ const figmaByVariableId = new Map();
687
+ for (const [key, token] of figmaTokens) {
688
+ const id = token.extensions?.["figma-console-mcp"]?.variableId;
689
+ if (typeof id === "string")
690
+ figmaByVariableId.set(id, { key, token });
691
+ }
692
+ const toCreate = [];
693
+ const toUpdate = [];
694
+ const toRename = [];
695
+ // Figma-side keys consumed by a rename — excluded from toDelete.
696
+ const renamedFigmaKeys = new Set();
697
+ const toDelete = [];
698
+ let unchanged = 0;
699
+ for (const [key, codeToken] of codeTokens) {
700
+ const figmaToken = figmaTokens.get(key);
701
+ if (!figmaToken) {
702
+ // No path match — try the ID-first match before classifying as a
703
+ // create. Guards: the matched Figma path must not ALSO exist in the
704
+ // code doc (then the ID was copy-pasted, not renamed), and each Figma
705
+ // variable can be claimed by at most one rename.
706
+ const extId = codeToken.extensions?.["figma-console-mcp"]?.variableId;
707
+ const idMatch = typeof extId === "string" ? figmaByVariableId.get(extId) : undefined;
708
+ if (idMatch &&
709
+ !codeTokens.has(idMatch.key) &&
710
+ !renamedFigmaKeys.has(idMatch.key)) {
711
+ renamedFigmaKeys.add(idMatch.key);
712
+ const valuesChanged = !valuesEqual(idMatch.token.values, codeToken.values);
713
+ const meta = diffVariableMeta(idMatch.token, codeToken);
714
+ toRename.push({
715
+ path: key,
716
+ from: idMatch.key,
717
+ variableId: extId,
718
+ newName: codeToken.path.join("/"),
719
+ changes: {
720
+ values: valuesChanged,
721
+ scopes: meta.scopesChanged,
722
+ codeSyntax: meta.codeSyntaxChanged,
723
+ },
724
+ });
725
+ continue;
726
+ }
727
+ toCreate.push({
728
+ path: key,
729
+ type: codeToken.type,
730
+ value: codeToken.values,
731
+ });
732
+ continue;
733
+ }
734
+ const valuesChanged = !valuesEqual(figmaToken.values, codeToken.values);
735
+ const meta = diffVariableMeta(figmaToken, codeToken);
736
+ if (valuesChanged || meta.scopesChanged || meta.codeSyntaxChanged) {
737
+ if (valuesChanged && !meta.scopesChanged && !meta.codeSyntaxChanged) {
738
+ // Value-only update — historical entry shape, no `changes` field, so
739
+ // pre-existing consumers/tests see exactly what they always saw.
740
+ toUpdate.push({
741
+ path: key,
742
+ // Figma-side values carry the transient rawColor floats — strip
743
+ // them so diff samples in the tool response stay shaped as before.
744
+ before: stripRawColorFromValues(figmaToken.values),
745
+ after: codeToken.values,
746
+ });
747
+ }
748
+ else {
749
+ toUpdate.push({
750
+ path: key,
751
+ before: valuesChanged
752
+ ? stripRawColorFromValues(figmaToken.values)
753
+ : meta.before,
754
+ after: valuesChanged ? codeToken.values : meta.after,
755
+ changes: {
756
+ values: valuesChanged,
757
+ scopes: meta.scopesChanged,
758
+ codeSyntax: meta.codeSyntaxChanged,
759
+ },
760
+ });
761
+ }
762
+ }
763
+ else {
764
+ unchanged++;
765
+ }
766
+ }
767
+ for (const key of figmaTokens.keys()) {
768
+ if (!codeTokens.has(key) && !renamedFigmaKeys.has(key)) {
769
+ // Reports as "would delete if strategy=replace" but defaults to
770
+ // preserve under merge strategy. Keys consumed by a rename are NOT
771
+ // deletions — the variable lives on under its new name.
772
+ toDelete.push({ path: key });
773
+ }
774
+ }
775
+ return { toCreate, toUpdate, toRename, toDelete, unchanged };
776
+ }
777
+ /**
778
+ * Structural equality for a token's mode-keyed values map. Order-independent
779
+ * so two tokens that have the same modes with the same values produce a
780
+ * match regardless of object insertion order.
781
+ *
782
+ * Recursive for composite values (typography, shadow) — those have nested
783
+ * objects too. Aliases are equal when both have the same `reference` string;
784
+ * literals are equal by deep value comparison.
785
+ *
786
+ * Each side is canonicalized to a dialect-agnostic form before comparing
787
+ * (see canonicalizeTokenValueForComparison): a DTCG 2025.10 color object
788
+ * equals the same color's legacy hex string (both quantized to 1/255 per
789
+ * channel), `{ value: 16, unit: "px" }` equals bare 16, and the transient
790
+ * rawColor field is ignored. Without this, importing a 2025-dialect file
791
+ * would report EVERY color as toUpdate on EVERY import, forever.
792
+ */
793
+ function valuesEqual(a, b) {
794
+ const aKeys = Object.keys(a).sort();
795
+ const bKeys = Object.keys(b).sort();
796
+ if (aKeys.length !== bKeys.length)
797
+ return false;
798
+ for (let i = 0; i < aKeys.length; i++) {
799
+ if (aKeys[i] !== bKeys[i])
800
+ return false;
801
+ if (!deepEqual(canonicalizeTokenValueForComparison(a[aKeys[i]]), canonicalizeTokenValueForComparison(b[bKeys[i]]))) {
802
+ return false;
803
+ }
804
+ }
805
+ return true;
806
+ }
807
+ function deepEqual(a, b) {
808
+ if (a === b)
809
+ return true;
810
+ if (typeof a !== typeof b)
811
+ return false;
812
+ if (a === null || b === null)
813
+ return a === b;
814
+ if (typeof a !== "object")
815
+ return a === b;
816
+ if (Array.isArray(a)) {
817
+ if (!Array.isArray(b) || a.length !== b.length)
818
+ return false;
819
+ return a.every((v, i) => deepEqual(v, b[i]));
820
+ }
821
+ const aObj = a;
822
+ const bObj = b;
823
+ const aKeys = Object.keys(aObj).sort();
824
+ const bKeys = Object.keys(bObj).sort();
825
+ if (aKeys.length !== bKeys.length)
826
+ return false;
827
+ for (let i = 0; i < aKeys.length; i++) {
828
+ if (aKeys[i] !== bKeys[i])
829
+ return false;
830
+ if (!deepEqual(aObj[aKeys[i]], bObj[bKeys[i]]))
831
+ return false;
832
+ }
833
+ return true;
834
+ }
835
+ /**
836
+ * Compare variable metadata (scopes + codeSyntax) between the Figma-side and
837
+ * code-side tokens.
838
+ *
839
+ * Semantics (deliberately merge-friendly):
840
+ * - Code-side ABSENT field = "no opinion" — never a change, so token files
841
+ * that predate this feature (or hand-authored files without extensions)
842
+ * can't silently reset Figma-side scopes/codeSyntax.
843
+ * - Scopes compare order-insensitively; []/["ALL_SCOPES"]/absent all
844
+ * normalize to the default (export omits the default, so a round-trip of
845
+ * an ALL_SCOPES variable is absent on both sides → unchanged).
846
+ * - codeSyntax compares by deep equality ({} counts as an explicit "clear
847
+ * every platform" opinion; absent counts as no opinion).
848
+ */
849
+ function diffVariableMeta(figmaToken, codeToken) {
850
+ const figmaExt = figmaToken.extensions?.["figma-console-mcp"] ?? {};
851
+ const codeExt = codeToken.extensions?.["figma-console-mcp"] ?? {};
852
+ let scopesChanged = false;
853
+ if (Array.isArray(codeExt.scopes)) {
854
+ const codeScopes = normalizeScopesForComparison(codeExt.scopes);
855
+ const figmaScopes = normalizeScopesForComparison(figmaExt.scopes);
856
+ scopesChanged = !deepEqual(codeScopes, figmaScopes);
857
+ }
858
+ let codeSyntaxChanged = false;
859
+ if (codeExt.codeSyntax !== undefined &&
860
+ codeExt.codeSyntax !== null &&
861
+ typeof codeExt.codeSyntax === "object" &&
862
+ !Array.isArray(codeExt.codeSyntax)) {
863
+ codeSyntaxChanged = !deepEqual(codeExt.codeSyntax, figmaExt.codeSyntax ?? {});
864
+ }
865
+ const before = {};
866
+ const after = {};
867
+ if (scopesChanged) {
868
+ before.scopes = figmaExt.scopes ?? ["ALL_SCOPES"];
869
+ after.scopes = codeExt.scopes;
870
+ }
871
+ if (codeSyntaxChanged) {
872
+ before.codeSyntax = figmaExt.codeSyntax ?? {};
873
+ after.codeSyntax = codeExt.codeSyntax;
874
+ }
875
+ return { scopesChanged, codeSyntaxChanged, before, after };
876
+ }
877
+ /**
878
+ * Normalize a scopes array to a sorted, default-collapsed form: absent,
879
+ * empty, and ["ALL_SCOPES"] all mean "the default scoping" in Figma.
880
+ */
881
+ function normalizeScopesForComparison(scopes) {
882
+ if (!Array.isArray(scopes) || scopes.length === 0)
883
+ return ["ALL_SCOPES"];
884
+ const cleaned = scopes.filter((s) => typeof s === "string");
885
+ if (cleaned.length === 0)
886
+ return ["ALL_SCOPES"];
887
+ if (cleaned.length === 1 && cleaned[0] === "ALL_SCOPES")
888
+ return ["ALL_SCOPES"];
889
+ return [...cleaned].sort();
890
+ }
891
+ /**
892
+ * Build a resolver that maps a DTCG alias reference (set-qualified
893
+ * `{theme.color.primary}`, bare `{color.primary}` when unambiguous, or the
894
+ * converter's `{__library:VariableID:...}` cross-library form) to a Figma
895
+ * variable ID.
896
+ *
897
+ * Resolution priority for a matched code-side token:
898
+ * 1. a variable created earlier in THIS apply run (createdIdByKey — the
899
+ * resolver closes over the mutable map, so the create phase's results
900
+ * are visible to the later update phase)
901
+ * 2. the live Figma snapshot's variable ID for the same set::path
902
+ * 3. "pending" when the target is queued in this run's toCreate batch
903
+ * 4. the token's own $extensions variableId (stale-but-recorded fallback)
904
+ * References that don't match a code-side token fall back to the live Figma
905
+ * snapshot lookup. Exported for test coverage.
906
+ */
907
+ export function makeAliasIdResolver(figmaDoc, codeDoc, toCreateKeys, createdIdByKey) {
908
+ const codeLookup = buildTokenLookup(codeDoc);
909
+ const figmaLookup = buildTokenLookup(figmaDoc);
910
+ // "SetName::dot.path" → live Figma variable ID.
911
+ const figmaIdByKey = new Map();
912
+ for (const set of figmaDoc.sets) {
913
+ for (const t of set.tokens) {
914
+ const id = t.extensions?.["figma-console-mcp"]?.variableId;
915
+ if (typeof id === "string") {
916
+ figmaIdByKey.set(`${set.name}::${t.path.join(".")}`, id);
917
+ }
918
+ }
919
+ }
920
+ return (reference) => {
921
+ const bare = reference.replace(/^\{|\}$/g, "");
922
+ // Cross-library references preserve the original Figma variable ID —
923
+ // usable directly as an alias target.
924
+ if (bare.startsWith("__library:")) {
925
+ return { id: bare.slice("__library:".length) };
926
+ }
927
+ const codeEntry = codeLookup.get(bare);
928
+ if (codeEntry) {
929
+ const key = `${codeEntry.setName}::${codeEntry.token.path.join(".")}`;
930
+ const createdId = createdIdByKey.get(key);
931
+ if (createdId)
932
+ return { id: createdId };
933
+ const liveId = figmaIdByKey.get(key);
934
+ if (liveId)
935
+ return { id: liveId };
936
+ if (toCreateKeys.has(key))
937
+ return { pending: key };
938
+ const extId = codeEntry.token.extensions?.["figma-console-mcp"]?.variableId;
939
+ if (typeof extId === "string")
940
+ return { id: extId };
941
+ return null;
942
+ }
943
+ // Not in the code document — the reference may point at a Figma-only
944
+ // token (present in the live snapshot but absent from the import file).
945
+ const figmaEntry = figmaLookup.get(bare);
946
+ if (figmaEntry) {
947
+ const id = figmaEntry.token.extensions?.["figma-console-mcp"]?.variableId;
948
+ if (typeof id === "string")
949
+ return { id };
950
+ }
951
+ return null;
952
+ };
953
+ }
954
+ /**
955
+ * Build a quick lookup of (collectionId, modeName) → modeId from the raw
956
+ * Figma payload. Needed because our internal model is keyed by mode name
957
+ * but the Plugin API wants the modeId.
958
+ */
959
+ function buildCollectionModeMap(payload) {
960
+ const out = new Map();
961
+ for (const c of payload.collections) {
962
+ const modes = new Map();
963
+ for (const m of c.modes ?? []) {
964
+ modes.set(m.name, m.modeId);
965
+ }
966
+ out.set(c.id, modes);
967
+ }
968
+ return out;
969
+ }
970
+ /**
971
+ * Convert a TokenValue back to Figma's native value shape. Required for the
972
+ * Plugin API's setValueForMode call.
973
+ *
974
+ * - color hex string "#RRGGBB(AA)" → { r, g, b, a } floats in [0, 1].
975
+ * Non-hex color strings ("rgb(255,0,0)", "transparent", named colors
976
+ * like "salmon") return skip-invalid instead of throwing — a throw here
977
+ * would abort the import mid-apply.
978
+ * - DTCG 2025.10 color objects → { r, g, b, a }: srgb components map
979
+ * directly (clamped to [0, 1]); non-srgb colorSpaces (display-p3,
980
+ * oklch, …) fall back to the object's `hex` field when present, else
981
+ * skip-invalid; hex-only objects are accepted too
982
+ * - FLOAT-typed number → number ("16px"-style strings and DTCG
983
+ * { value, unit } objects are parsed to their numeric part; anything
984
+ * that still comes out NaN is skipped rather than pushed to Figma)
985
+ * - STRING-typed string → string
986
+ * - BOOLEAN → boolean
987
+ * - Alias references return `skip-alias` so the CALLER resolves them —
988
+ * both apply paths pass the reference through their alias-ID resolver
989
+ * (just-created variable → live snapshot → pending in batch → recorded
990
+ * $extensions ID) and write { type: "VARIABLE_ALIAS", id }. Only when
991
+ * the resolver comes up empty does the reference stay skipped, with a
992
+ * warning rather than a silent drop.
993
+ *
994
+ * Exported for test coverage of the value-conversion edge cases.
995
+ */
996
+ export function tokenValueToFigma(value, resolvedType) {
997
+ if (value.reference) {
998
+ // References aren't converted here — the caller resolves them to a
999
+ // Figma variable ID via its alias-ID resolver and writes a real
1000
+ // { type: "VARIABLE_ALIAS", id } payload. Returning skip-alias hands
1001
+ // the reference back for that resolution; if the resolver can't find
1002
+ // a target, the caller surfaces a warning instead of silently wiping
1003
+ // the reference with a literal.
1004
+ return { kind: "skip-alias", reference: value.reference };
1005
+ }
1006
+ if (value.literal === undefined || value.literal === null) {
1007
+ return { kind: "skip-empty" };
1008
+ }
1009
+ let figmaValue;
1010
+ if (resolvedType === "COLOR" && typeof value.literal === "string") {
1011
+ // Guarded: hexToRgba throws on anything that isn't a hex color
1012
+ // ("rgb(255,0,0)", "transparent", "salmon", "oklch(...)"). A throw here
1013
+ // would abort the whole import AFTER the create phase already mutated
1014
+ // Figma — so malformed colors become a per-value skip instead.
1015
+ try {
1016
+ figmaValue = hexToRgba(value.literal);
1017
+ }
1018
+ catch (err) {
1019
+ return {
1020
+ kind: "skip-invalid",
1021
+ reason: `cannot convert ${JSON.stringify(value.literal)} to a Figma color (${err instanceof Error ? err.message : String(err)})`,
1022
+ };
1023
+ }
1024
+ }
1025
+ else if (resolvedType === "COLOR" &&
1026
+ typeof value.literal === "object" &&
1027
+ value.literal !== null &&
1028
+ !Array.isArray(value.literal)) {
1029
+ // DTCG 2025.10 object-form color. Without this branch the object fell
1030
+ // through to String(literal) → "[object Object]" pushed at a COLOR
1031
+ // variable.
1032
+ const converted = colorObjectToFigmaRgba(value.literal);
1033
+ if (converted.kind !== "value")
1034
+ return converted;
1035
+ figmaValue = converted.value;
1036
+ }
1037
+ else if (resolvedType === "FLOAT") {
1038
+ const parsed = parseNumericLiteral(value.literal);
1039
+ if (Number.isNaN(parsed)) {
1040
+ // Never push NaN into setValueForMode — skip with a reason instead.
1041
+ return {
1042
+ kind: "skip-invalid",
1043
+ reason: `cannot convert ${JSON.stringify(value.literal)} to a number for a FLOAT variable`,
1044
+ };
1045
+ }
1046
+ figmaValue = parsed;
1047
+ }
1048
+ else if (resolvedType === "BOOLEAN") {
1049
+ figmaValue = Boolean(value.literal);
1050
+ }
1051
+ else {
1052
+ figmaValue = typeof value.literal === "string" ? value.literal : String(value.literal);
1053
+ }
1054
+ return { kind: "value", value: figmaValue };
1055
+ }
1056
+ /**
1057
+ * Convert a DTCG 2025.10 color object literal to Figma's { r, g, b, a }
1058
+ * floats. Handles, in priority order:
1059
+ * 1. srgb (or unspecified) colorSpace + 3 numeric components → direct
1060
+ * mapping, each channel clamped to [0, 1]; optional `alpha` (default 1)
1061
+ * 2. any object with a `hex` string field (covers non-srgb colorSpaces
1062
+ * like display-p3/oklch that carry the hex interop fallback, and
1063
+ * hex-only objects) → hexToRgba, with the `alpha` field taking
1064
+ * precedence over hex-embedded alpha when present
1065
+ * 3. non-srgb colorSpace without a hex fallback → skip-invalid (we can't
1066
+ * do color-space conversion, and guessing would corrupt the variable)
1067
+ */
1068
+ function colorObjectToFigmaRgba(obj) {
1069
+ const colorSpace = typeof obj.colorSpace === "string" ? obj.colorSpace : undefined;
1070
+ const comps = obj.components;
1071
+ if ((colorSpace === undefined || colorSpace === "srgb") &&
1072
+ Array.isArray(comps) &&
1073
+ comps.length === 3 &&
1074
+ comps.every((c) => typeof c === "number")) {
1075
+ const [r, g, b] = comps.map(clamp01);
1076
+ const a = typeof obj.alpha === "number" ? clamp01(obj.alpha) : 1;
1077
+ return { kind: "value", value: { r, g, b, a } };
1078
+ }
1079
+ if (typeof obj.hex === "string") {
1080
+ try {
1081
+ const rgba = hexToRgba(obj.hex);
1082
+ if (typeof obj.alpha === "number")
1083
+ rgba.a = clamp01(obj.alpha);
1084
+ return { kind: "value", value: rgba };
1085
+ }
1086
+ catch {
1087
+ return {
1088
+ kind: "skip-invalid",
1089
+ reason: `color object has an unparseable hex field ${JSON.stringify(obj.hex)}`,
1090
+ };
1091
+ }
1092
+ }
1093
+ if (colorSpace !== undefined && colorSpace !== "srgb") {
1094
+ return {
1095
+ kind: "skip-invalid",
1096
+ reason: `unsupported colorSpace "${colorSpace}" without hex fallback`,
1097
+ };
1098
+ }
1099
+ return {
1100
+ kind: "skip-invalid",
1101
+ reason: `cannot convert ${JSON.stringify(obj)} to a Figma color — expected srgb components or a hex field`,
1102
+ };
1103
+ }
1104
+ /**
1105
+ * Parse a token literal into a number for FLOAT variables. Handles:
1106
+ * - plain numbers → as-is
1107
+ * - unit-bearing dimension strings ("16px", "1.5rem", "-4pt") → numeric part
1108
+ * - DTCG dimension AND duration objects: { value: 16, unit: "px" } → 16,
1109
+ * { value: 300, unit: "ms" } → 300, and { value: 0.3, unit: "s" } → 300
1110
+ * — seconds MULTIPLY to milliseconds so the write path agrees with the
1111
+ * diff canonicalization (canonicalizeTokenValueForComparison, which
1112
+ * treats {0.3, "s"} ≡ {300, "ms"} ≡ 300). If the write path took the
1113
+ * raw 0.3 instead, the diff would report a change, apply would write a
1114
+ * 1000x-wrong value, and every subsequent import would rewrite it
1115
+ * forever. Other units (px/rem/…) keep the raw value — Figma FLOAT is
1116
+ * unitless. True TIMING-typed variables are skipped before import ever
1117
+ * reaches this path (Plugin API can't write them), so plain FLOAT is
1118
+ * the only consumer here.
1119
+ * Anything unparseable returns NaN — callers must skip (never push NaN
1120
+ * into setValueForMode).
1121
+ */
1122
+ function parseNumericLiteral(literal) {
1123
+ if (typeof literal === "number")
1124
+ return literal;
1125
+ if (typeof literal === "string") {
1126
+ const match = literal
1127
+ .trim()
1128
+ .match(/^(-?(?:\d+\.?\d*|\.\d+))\s*(px|rem|em|pt|dp|ms|s|%)?$/i);
1129
+ if (match)
1130
+ return Number(match[1]);
1131
+ return Number(literal);
1132
+ }
1133
+ if (literal !== null &&
1134
+ typeof literal === "object" &&
1135
+ "value" in literal) {
1136
+ const obj = literal;
1137
+ const inner = parseNumericLiteral(obj.value);
1138
+ // s → ms, matching the diff-side canonicalization (see doc above).
1139
+ if (obj.unit === "s")
1140
+ return inner * 1000;
1141
+ return inner;
1142
+ }
1143
+ return Number(literal);
1144
+ }
1145
+ /**
1146
+ * Parse a hex color string to Figma rgba floats. Validates through the
1147
+ * dialect module's colorLiteralToCanonicalHex (via hexToRawRgba) so ONLY
1148
+ * genuine 3/6/8-digit hex strings pass — the previous implementation
1149
+ * dispatched on string LENGTH alone, so named colors like "red" (3 chars)
1150
+ * or "salmon" (6 chars) produced NaN channels that reached setValueForMode.
1151
+ * A bare hex without the leading "#" is still tolerated (historical
1152
+ * behavior). Throws on anything else; tokenValueToFigma catches and turns
1153
+ * it into a skip-invalid.
1154
+ */
1155
+ function hexToRgba(hex) {
1156
+ const trimmed = hex.trim();
1157
+ const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
1158
+ const rgba = hexToRawRgba(normalized);
1159
+ if (!rgba) {
1160
+ throw new Error(`[figma-console-mcp] Invalid hex color ${JSON.stringify(hex)} — expected #RGB, #RRGGBB, or #RRGGBBAA.`);
1161
+ }
1162
+ return rgba;
1163
+ }
1164
+ /**
1165
+ * Walk the toUpdate diff entries and translate each into a VariableUpdate.
1166
+ * Tokens that lack a Figma variable ID (never been synced) or have no
1167
+ * resolvable value for any mode get skipped with a warning.
1168
+ */
1169
+ function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, warnings, resolveAliasId) {
1170
+ // Build lookups: setName::tokenPath → (figmaToken, codeToken)
1171
+ const figmaLookup = new Map();
1172
+ for (const set of figmaDoc.sets) {
1173
+ for (const t of set.tokens) {
1174
+ figmaLookup.set(`${set.name}::${t.path.join(".")}`, { token: t, set });
1175
+ }
1176
+ }
1177
+ const codeLookup = new Map();
1178
+ for (const set of codeDoc.sets) {
1179
+ for (const t of set.tokens) {
1180
+ codeLookup.set(`${set.name}::${t.path.join(".")}`, { token: t, set });
1181
+ }
1182
+ }
1183
+ const updates = [];
1184
+ for (const entry of toUpdate) {
1185
+ // Rename entries look up the code token at its NEW path (codePath) and
1186
+ // the Figma variable at its OLD path (path).
1187
+ const codeMatch = codeLookup.get(entry.codePath ?? entry.path);
1188
+ const figmaMatch = figmaLookup.get(entry.path);
1189
+ if (!codeMatch || !figmaMatch)
1190
+ continue;
1191
+ const figmaToken = figmaMatch.token;
1192
+ const variableId = figmaToken.extensions?.["figma-console-mcp"]?.variableId;
1193
+ const collectionId = figmaToken.extensions?.["figma-console-mcp"]?.collectionId;
1194
+ if (!variableId || !collectionId) {
1195
+ warnings.push(`Cannot update ${entry.path} — missing Figma variable ID in extensions. Run figma_export_tokens first to populate.`);
1196
+ continue;
1197
+ }
1198
+ const modeMap = collectionModeMap.get(collectionId);
1199
+ if (!modeMap) {
1200
+ warnings.push(`Cannot update ${entry.path} — collection ${collectionId} not found in current Figma state.`);
1201
+ continue;
1202
+ }
1203
+ // Map our token type → Figma resolvedType. Prefer the actual
1204
+ // Figma-native type recorded at export time
1205
+ // (extensions.figmaResolvedType) — critical so FLOAT variables whose
1206
+ // token type was name-inferred as "duration" keep writing as FLOAT,
1207
+ // while true TIMING/EASING variables are recognized. Fall back to
1208
+ // inferring from the DTCG type when the extension is absent.
1209
+ const recordedType = figmaToken.extensions?.["figma-console-mcp"]?.figmaResolvedType;
1210
+ const figmaNativeType = typeof recordedType === "string"
1211
+ ? recordedType
1212
+ : inferFigmaResolvedType(figmaToken.type);
1213
+ if (figmaNativeType === "TIMING" || figmaNativeType === "EASING") {
1214
+ // The Figma Plugin API cannot create or setValueForMode on
1215
+ // TIMING/EASING variables — only BOOLEAN/COLOR/FLOAT/STRING are
1216
+ // writable. Sending these would be rejected (or worse); skip loudly.
1217
+ warnings.push(`Skipped ${entry.path} — Figma Plugin API cannot write ${figmaNativeType === "TIMING" ? "Timing" : "Easing"} variables (only BOOLEAN/COLOR/FLOAT/STRING are writable). Update this variable in the Figma UI instead.`);
1218
+ continue;
1219
+ }
1220
+ const resolvedType = figmaNativeType;
1221
+ // Value updates apply unless the diff explicitly flagged this entry as
1222
+ // metadata-only (changes.values === false) — re-pushing unchanged values
1223
+ // would be wasteful and could trip alias-resolution warnings.
1224
+ const wantValues = !entry.changes || entry.changes.values;
1225
+ const valuesByMode = {};
1226
+ const valueEntries = wantValues
1227
+ ? Object.entries(codeMatch.token.values)
1228
+ : [];
1229
+ for (const [modeName, value] of valueEntries) {
1230
+ const modeId = modeMap.get(modeName);
1231
+ if (!modeId) {
1232
+ warnings.push(`Cannot update ${entry.path} (mode "${modeName}") — modeId not found in Figma collection.`);
1233
+ continue;
1234
+ }
1235
+ const conversion = tokenValueToFigma(value, resolvedType);
1236
+ if (conversion.kind === "skip-alias") {
1237
+ // Alias-target update: resolve the reference to a Figma variable ID
1238
+ // and write it as a native variable alias. Creates run before
1239
+ // updates, so references to just-created variables resolve too.
1240
+ const resolved = resolveAliasId?.(conversion.reference);
1241
+ if (resolved && "id" in resolved) {
1242
+ valuesByMode[modeId] = { type: "VARIABLE_ALIAS", id: resolved.id };
1243
+ continue;
1244
+ }
1245
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${conversion.reference}" could not be resolved to an existing or newly-created Figma variable. Fix the reference, or edit the alias target's value instead.`);
1246
+ continue;
1247
+ }
1248
+ if (conversion.kind === "skip-invalid") {
1249
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — ${conversion.reason}.`);
1250
+ continue;
1251
+ }
1252
+ if (conversion.kind === "skip-empty")
1253
+ continue;
1254
+ valuesByMode[modeId] = conversion.value;
1255
+ }
1256
+ // Metadata ops (scopes / codeSyntax) — flagged by the diff phase.
1257
+ let scopes;
1258
+ if (entry.changes?.scopes) {
1259
+ const codeScopes = codeMatch.token.extensions?.["figma-console-mcp"]?.scopes;
1260
+ if (Array.isArray(codeScopes) && codeScopes.length > 0) {
1261
+ scopes = codeScopes;
1262
+ }
1263
+ else {
1264
+ // Explicit empty/["ALL_SCOPES"] normalizes to the Figma default.
1265
+ scopes = ["ALL_SCOPES"];
1266
+ }
1267
+ }
1268
+ let codeSyntaxOps;
1269
+ if (entry.changes?.codeSyntax) {
1270
+ const codeCS = codeMatch.token.extensions?.["figma-console-mcp"]?.codeSyntax ?? {};
1271
+ const figmaCS = figmaToken.extensions?.["figma-console-mcp"]?.codeSyntax ?? {};
1272
+ const toSet = {};
1273
+ for (const [platform, value] of Object.entries(codeCS)) {
1274
+ if (typeof value === "string" && figmaCS[platform] !== value) {
1275
+ toSet[platform] = value;
1276
+ }
1277
+ }
1278
+ const toRemove = Object.keys(figmaCS).filter((p) => !(p in codeCS));
1279
+ if (Object.keys(toSet).length > 0 || toRemove.length > 0) {
1280
+ codeSyntaxOps = {
1281
+ ...(Object.keys(toSet).length > 0 ? { set: toSet } : {}),
1282
+ ...(toRemove.length > 0 ? { remove: toRemove } : {}),
1283
+ };
1284
+ }
1285
+ }
1286
+ if (Object.keys(valuesByMode).length === 0 &&
1287
+ scopes === undefined &&
1288
+ codeSyntaxOps === undefined &&
1289
+ entry.newName === undefined) {
1290
+ continue;
1291
+ }
1292
+ updates.push({
1293
+ variableId,
1294
+ variableName: figmaToken.path.join("/"),
1295
+ resolvedType,
1296
+ valuesByMode,
1297
+ ...(entry.newName !== undefined ? { newName: entry.newName } : {}),
1298
+ ...(scopes !== undefined ? { scopes } : {}),
1299
+ ...(codeSyntaxOps !== undefined ? { codeSyntax: codeSyntaxOps } : {}),
1300
+ });
1301
+ }
1302
+ return updates;
1303
+ }
1304
+ /**
1305
+ * Map our internal TokenType to Figma's variable resolvedType.
1306
+ *
1307
+ * duration/cubicBezier map to the Config-2026 TIMING/EASING variable types
1308
+ * — but note the Figma Plugin API CANNOT create or setValueForMode on
1309
+ * TIMING/EASING variables (only BOOLEAN/COLOR/FLOAT/STRING are writable),
1310
+ * so callers must skip those with a warning instead of pushing them.
1311
+ * buildUpdatePayloads prefers the extension-recorded figmaResolvedType over
1312
+ * this inference, so FLOAT variables whose token type was name-inferred as
1313
+ * "duration" still write correctly as FLOAT.
1314
+ */
1315
+ function inferFigmaResolvedType(type) {
1316
+ if (type === "color")
1317
+ return "COLOR";
1318
+ if (type === "boolean")
1319
+ return "BOOLEAN";
1320
+ if (type === "string" || type === "fontFamily")
1321
+ return "STRING";
1322
+ if (type === "duration")
1323
+ return "TIMING";
1324
+ if (type === "cubicBezier")
1325
+ return "EASING";
1326
+ return "FLOAT"; // dimension, number, fontWeight, etc.
1327
+ }
1328
+ /**
1329
+ * Push variable updates to Figma via executeCodeViaUI. The plugin runs the
1330
+ * inline script in its sandbox, calling figma.variables.setValueForMode for
1331
+ * each (variableId, modeId, value) tuple.
1332
+ */
1333
+ async function applyUpdates(connector, updates) {
1334
+ // Serialize the update list into the script payload. JSON.stringify
1335
+ // handles escape correctly even with nested objects (RGBA color values).
1336
+ const payload = JSON.stringify(updates);
1337
+ const script = `
1338
+ const updates = ${payload};
1339
+ const results = [];
1340
+ for (const u of updates) {
1341
+ try {
1342
+ const variable = await figma.variables.getVariableByIdAsync(u.variableId);
1343
+ if (!variable) {
1344
+ results.push({ id: u.variableId, success: false, error: "Variable not found in current file" });
1345
+ continue;
1346
+ }
1347
+ // Rename FIRST — a rename entry may carry no value writes at all.
1348
+ if (u.newName) {
1349
+ variable.name = u.newName;
1350
+ }
1351
+ let appliedModes = 0;
1352
+ for (const modeId in u.valuesByMode) {
1353
+ variable.setValueForMode(modeId, u.valuesByMode[modeId]);
1354
+ appliedModes++;
1355
+ }
1356
+ // Metadata writes — scopes replace wholesale; codeSyntax applies
1357
+ // per-platform sets then removals (removeVariableCodeSyntax is the
1358
+ // Plugin API's deletion primitive).
1359
+ if (u.scopes) {
1360
+ variable.scopes = u.scopes;
1361
+ }
1362
+ if (u.codeSyntax) {
1363
+ if (u.codeSyntax.set) {
1364
+ for (const platform in u.codeSyntax.set) {
1365
+ variable.setVariableCodeSyntax(platform, u.codeSyntax.set[platform]);
1366
+ }
1367
+ }
1368
+ if (u.codeSyntax.remove) {
1369
+ for (const platform of u.codeSyntax.remove) {
1370
+ variable.removeVariableCodeSyntax(platform);
1371
+ }
1372
+ }
1373
+ }
1374
+ results.push({ id: u.variableId, name: variable.name, success: true, appliedModes });
1375
+ } catch (err) {
1376
+ results.push({ id: u.variableId, success: false, error: String(err && err.message || err) });
1377
+ }
1378
+ }
1379
+ return {
1380
+ applied: results.filter(r => r.success).length,
1381
+ failed: results.filter(r => !r.success).length,
1382
+ results,
1383
+ };
1384
+ `;
1385
+ const execResult = await connector.executeCodeViaUI(script, 30000);
1386
+ if (!execResult?.success) {
1387
+ return {
1388
+ applied: 0,
1389
+ failed: updates.length,
1390
+ errors: [
1391
+ {
1392
+ variableId: "<batch>",
1393
+ error: execResult?.error ??
1394
+ "Plugin executeCodeViaUI returned an error or timed out.",
1395
+ },
1396
+ ],
1397
+ };
1398
+ }
1399
+ const inner = execResult.result ?? execResult;
1400
+ const errors = (inner.results ?? [])
1401
+ .filter((r) => !r.success)
1402
+ .map((r) => ({ variableId: r.id, error: r.error }));
1403
+ return {
1404
+ applied: inner.applied ?? 0,
1405
+ failed: inner.failed ?? 0,
1406
+ errors,
1407
+ };
1408
+ }
1409
+ const WRITABLE_RESOLVED_TYPES = new Set(["COLOR", "FLOAT", "STRING", "BOOLEAN"]);
1410
+ /**
1411
+ * Translate the diff's toCreate entries into a CreatePlan.
1412
+ *
1413
+ * - Sets with no matching Figma collection (matched by the round-trip
1414
+ * figmaCollectionId first, then by name) become newCollections carrying
1415
+ * the set's FULL mode list.
1416
+ * - Variables missing from an existing collection go to that collection,
1417
+ * with values pre-resolved to modeIds; values for modes the collection
1418
+ * doesn't have are skipped with a warning.
1419
+ * - resolvedType honors $extensions.figmaResolvedType when recorded, else
1420
+ * falls back to inferFigmaResolvedType. TIMING/EASING variables are
1421
+ * skipped with a warning — the Figma Plugin API cannot create them.
1422
+ * - Literal values convert via tokenValueToFigma (both DTCG dialects);
1423
+ * alias values resolve via the alias-ID resolver (existing target →
1424
+ * "alias", within-batch target → "alias-pending", unresolvable → skip
1425
+ * with warning).
1426
+ *
1427
+ * Exported for test coverage.
1428
+ */
1429
+ export function buildCreatePlan(toCreate, codeDoc, figmaPayload, resolveAliasId, warnings) {
1430
+ // Code-side lookup: "SetName::dot.path" → { set, token }.
1431
+ const codeByKey = new Map();
1432
+ for (const set of codeDoc.sets) {
1433
+ for (const token of set.tokens) {
1434
+ codeByKey.set(`${set.name}::${token.path.join(".")}`, { set, token });
1435
+ }
1436
+ }
1437
+ // Figma collection lookup by ID and by name.
1438
+ const collectionById = new Map();
1439
+ const collectionByName = new Map();
1440
+ for (const c of figmaPayload.collections) {
1441
+ collectionById.set(c.id, c);
1442
+ collectionByName.set(c.name, c);
1443
+ }
1444
+ const newBySet = new Map();
1445
+ const existingById = new Map();
1446
+ for (const entry of toCreate) {
1447
+ const match = codeByKey.get(entry.path);
1448
+ if (!match)
1449
+ continue; // Defensive — toCreate keys come from codeDoc.
1450
+ const { set, token } = match;
1451
+ // Which Figma collection does this set map to? Round-trip collection ID
1452
+ // wins; name match second; otherwise the set needs a new collection.
1453
+ const existingCollection = (set.meta?.figmaCollectionId
1454
+ ? collectionById.get(set.meta.figmaCollectionId)
1455
+ : undefined) ?? collectionByName.get(set.name);
1456
+ // Resolve the Figma-native type, honoring the recorded resolvedType.
1457
+ const recorded = token.extensions?.["figma-console-mcp"]?.figmaResolvedType;
1458
+ const figmaNativeType = typeof recorded === "string" &&
1459
+ ["COLOR", "FLOAT", "STRING", "BOOLEAN", "TIMING", "EASING"].includes(recorded)
1460
+ ? recorded
1461
+ : inferFigmaResolvedType(token.type);
1462
+ if (!WRITABLE_RESOLVED_TYPES.has(figmaNativeType)) {
1463
+ warnings.push(`Skipped create for ${entry.path} — Figma Plugin API cannot create ${figmaNativeType === "TIMING" ? "Timing" : "Easing"} variables (only BOOLEAN/COLOR/FLOAT/STRING are creatable). Create this variable in the Figma UI instead.`);
1464
+ continue;
1465
+ }
1466
+ const resolvedType = figmaNativeType;
1467
+ // Mode-name → modeId map for existing collections.
1468
+ const modeIdByName = new Map();
1469
+ if (existingCollection) {
1470
+ for (const m of existingCollection.modes ?? []) {
1471
+ modeIdByName.set(m.name, m.modeId);
1472
+ }
1473
+ }
1474
+ const values = [];
1475
+ for (const [modeName, value] of Object.entries(token.values)) {
1476
+ let modeKeying;
1477
+ if (existingCollection) {
1478
+ const modeId = modeIdByName.get(modeName);
1479
+ if (!modeId) {
1480
+ warnings.push(`Cannot set ${entry.path} (mode "${modeName}") — mode not found in existing Figma collection "${set.name}". Add the mode in Figma first (figma_add_mode).`);
1481
+ continue;
1482
+ }
1483
+ modeKeying = { modeId };
1484
+ }
1485
+ else {
1486
+ modeKeying = { modeName };
1487
+ }
1488
+ if (value?.reference) {
1489
+ const resolved = resolveAliasId(value.reference);
1490
+ if (!resolved) {
1491
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${value.reference}" could not be resolved to an existing or newly-created Figma variable.`);
1492
+ continue;
1493
+ }
1494
+ if ("id" in resolved) {
1495
+ values.push({ ...modeKeying, kind: "alias", targetId: resolved.id });
1496
+ }
1497
+ else {
1498
+ values.push({
1499
+ ...modeKeying,
1500
+ kind: "alias-pending",
1501
+ targetKey: resolved.pending,
1502
+ });
1503
+ }
1504
+ continue;
1505
+ }
1506
+ const conversion = tokenValueToFigma(value, resolvedType);
1507
+ if (conversion.kind === "skip-invalid") {
1508
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — ${conversion.reason}.`);
1509
+ continue;
1510
+ }
1511
+ if (conversion.kind !== "value")
1512
+ continue; // skip-empty / skip-alias (handled above)
1513
+ values.push({ ...modeKeying, kind: "literal", value: conversion.value });
1514
+ }
1515
+ // Stashed variable metadata rides along to creation. Scopes: only
1516
+ // meaningful (non-default) arrays; codeSyntax: only non-empty maps.
1517
+ const ext = token.extensions?.["figma-console-mcp"] ?? {};
1518
+ const createScopes = Array.isArray(ext.scopes) &&
1519
+ ext.scopes.length > 0 &&
1520
+ !(ext.scopes.length === 1 && ext.scopes[0] === "ALL_SCOPES")
1521
+ ? ext.scopes.filter((s) => typeof s === "string")
1522
+ : undefined;
1523
+ const createCodeSyntax = ext.codeSyntax &&
1524
+ typeof ext.codeSyntax === "object" &&
1525
+ !Array.isArray(ext.codeSyntax) &&
1526
+ Object.keys(ext.codeSyntax).length > 0
1527
+ ? ext.codeSyntax
1528
+ : undefined;
1529
+ const def = {
1530
+ key: entry.path,
1531
+ name: token.path.join("/"),
1532
+ resolvedType,
1533
+ ...(token.description ? { description: token.description } : {}),
1534
+ values,
1535
+ ...(createScopes && createScopes.length > 0
1536
+ ? { scopes: createScopes }
1537
+ : {}),
1538
+ ...(createCodeSyntax ? { codeSyntax: createCodeSyntax } : {}),
1539
+ };
1540
+ if (existingCollection) {
1541
+ let bucket = existingById.get(existingCollection.id);
1542
+ if (!bucket) {
1543
+ bucket = { collectionId: existingCollection.id, variables: [] };
1544
+ existingById.set(existingCollection.id, bucket);
1545
+ }
1546
+ bucket.variables.push(def);
1547
+ }
1548
+ else {
1549
+ let bucket = newBySet.get(set.name);
1550
+ if (!bucket) {
1551
+ bucket = {
1552
+ setName: set.name,
1553
+ modes: set.modes?.length ? [...set.modes] : ["Default"],
1554
+ variables: [],
1555
+ };
1556
+ newBySet.set(set.name, bucket);
1557
+ }
1558
+ bucket.variables.push(def);
1559
+ }
1560
+ }
1561
+ return {
1562
+ newCollections: [...newBySet.values()],
1563
+ existingCollections: [...existingById.values()],
1564
+ };
1565
+ }
1566
+ /**
1567
+ * Execute a CreatePlan via the plugin bridge in ONE batched script (same
1568
+ * executeCodeViaUI transport the update phase and figma_setup_design_tokens
1569
+ * use — see write-tools.ts for the precedent):
1570
+ *
1571
+ * Pass 0 — create missing collections; rename the auto-created default
1572
+ * mode to the set's first mode, addMode() for the rest.
1573
+ * Pass 1 — create every variable and set its LITERAL values.
1574
+ * Pass 2 — set alias values, after all variables exist, so aliases among
1575
+ * just-created variables resolve via the in-script created-ID map.
1576
+ *
1577
+ * Per-item failures are collected and surfaced without failing the batch.
1578
+ */
1579
+ async function applyCreates(connector, plan) {
1580
+ const payload = JSON.stringify(plan);
1581
+ const totalVars = plan.newCollections.reduce((n, c) => n + c.variables.length, 0) +
1582
+ plan.existingCollections.reduce((n, c) => n + c.variables.length, 0);
1583
+ const timeout = Math.min(60000, Math.max(15000, totalVars * 200 + plan.newCollections.length * 500));
1584
+ const script = `
1585
+ const plan = ${payload};
1586
+ const results = [];
1587
+ const aliasFailures = [];
1588
+ const createdIds = {};
1589
+ const createdCollections = [];
1590
+ const pendingAliases = [];
1591
+
1592
+ function createVariableWithValues(def, collection, modeMap) {
1593
+ try {
1594
+ const variable = figma.variables.createVariable(def.name, collection, def.resolvedType);
1595
+ if (def.description) variable.description = def.description;
1596
+ createdIds[def.key] = variable.id;
1597
+ let appliedModes = 0;
1598
+ const valueErrors = [];
1599
+ // Variable metadata (scopes / per-platform code syntax) — failures
1600
+ // are per-item value errors, never batch failures.
1601
+ if (def.scopes) {
1602
+ try { variable.scopes = def.scopes; }
1603
+ catch (err) { valueErrors.push('scopes: ' + String(err && err.message || err)); }
1604
+ }
1605
+ if (def.codeSyntax) {
1606
+ for (const platform in def.codeSyntax) {
1607
+ try { variable.setVariableCodeSyntax(platform, def.codeSyntax[platform]); }
1608
+ catch (err) { valueErrors.push('codeSyntax ' + platform + ': ' + String(err && err.message || err)); }
1609
+ }
1610
+ }
1611
+ for (const val of def.values) {
1612
+ const modeId = val.modeId || (modeMap ? modeMap[val.modeName] : null);
1613
+ if (!modeId) { valueErrors.push('unknown mode: ' + (val.modeName || val.modeId)); continue; }
1614
+ if (val.kind === 'literal') {
1615
+ try { variable.setValueForMode(modeId, val.value); appliedModes++; }
1616
+ catch (err) { valueErrors.push('mode ' + modeId + ': ' + String(err && err.message || err)); }
1617
+ } else {
1618
+ // Alias values apply in pass 2, after ALL variables exist.
1619
+ pendingAliases.push({ variable: variable, key: def.key, modeId: modeId, targetId: val.targetId || null, targetKey: val.targetKey || null });
1620
+ }
1621
+ }
1622
+ results.push({ key: def.key, name: def.name, id: variable.id, success: true, appliedModes: appliedModes, valueErrors: valueErrors });
1623
+ } catch (err) {
1624
+ results.push({ key: def.key, name: def.name, success: false, error: String(err && err.message || err) });
1625
+ }
1626
+ }
1627
+
1628
+ // Pass 0 + 1a — new collections (full mode list), then their variables.
1629
+ for (const nc of plan.newCollections) {
1630
+ let collection;
1631
+ const modeMap = {};
1632
+ try {
1633
+ collection = figma.variables.createVariableCollection(nc.setName);
1634
+ const defaultModeId = collection.modes[0].modeId;
1635
+ collection.renameMode(defaultModeId, nc.modes[0]);
1636
+ modeMap[nc.modes[0]] = defaultModeId;
1637
+ for (let i = 1; i < nc.modes.length; i++) {
1638
+ modeMap[nc.modes[i]] = collection.addMode(nc.modes[i]);
1639
+ }
1640
+ createdCollections.push({ name: nc.setName, id: collection.id });
1641
+ } catch (err) {
1642
+ // Roll back the orphaned collection if creation succeeded but mode
1643
+ // setup (renameMode/addMode) threw — otherwise a half-configured
1644
+ // empty collection is left behind in the file.
1645
+ let rolledBack = false;
1646
+ if (collection) {
1647
+ try { collection.remove(); rolledBack = true; } catch (removeErr) {}
1648
+ }
1649
+ const suffix = rolledBack ? ' (partially-created collection rolled back)' : '';
1650
+ for (const def of nc.variables) {
1651
+ results.push({ key: def.key, name: def.name, success: false, error: 'collection "' + nc.setName + '" creation failed' + suffix + ': ' + String(err && err.message || err) });
1652
+ }
1653
+ continue;
1654
+ }
1655
+ for (const def of nc.variables) createVariableWithValues(def, collection, modeMap);
1656
+ }
1657
+
1658
+ // Pass 1b — variables missing from EXISTING collections.
1659
+ for (const group of plan.existingCollections) {
1660
+ const collection = await figma.variables.getVariableCollectionByIdAsync(group.collectionId);
1661
+ if (!collection) {
1662
+ for (const def of group.variables) {
1663
+ results.push({ key: def.key, name: def.name, success: false, error: 'collection not found: ' + group.collectionId });
1664
+ }
1665
+ continue;
1666
+ }
1667
+ for (const def of group.variables) createVariableWithValues(def, collection, null);
1668
+ }
1669
+
1670
+ // Pass 2 — alias values. Targets are either pre-resolved IDs or keys of
1671
+ // variables created above (createdIds).
1672
+ for (const pa of pendingAliases) {
1673
+ try {
1674
+ const targetId = pa.targetId || (pa.targetKey ? createdIds[pa.targetKey] : null);
1675
+ if (!targetId) {
1676
+ aliasFailures.push({ key: pa.key, error: 'alias target was not created: ' + (pa.targetKey || 'unknown') });
1677
+ continue;
1678
+ }
1679
+ pa.variable.setValueForMode(pa.modeId, { type: 'VARIABLE_ALIAS', id: targetId });
1680
+ } catch (err) {
1681
+ aliasFailures.push({ key: pa.key, error: 'alias: ' + String(err && err.message || err) });
1682
+ }
1683
+ }
1684
+
1685
+ return {
1686
+ createdCollections: createdCollections,
1687
+ created: results.filter(r => r.success).length,
1688
+ failed: results.filter(r => !r.success).length,
1689
+ results: results,
1690
+ aliasFailures: aliasFailures,
1691
+ createdIds: createdIds,
1692
+ };
1693
+ `;
1694
+ const execResult = await connector.executeCodeViaUI(script, timeout);
1695
+ if (!execResult?.success) {
1696
+ return {
1697
+ created: 0,
1698
+ createdCollections: 0,
1699
+ failed: totalVars,
1700
+ errors: [
1701
+ {
1702
+ variableId: "<create-batch>",
1703
+ error: execResult?.error ??
1704
+ "Plugin executeCodeViaUI returned an error or timed out.",
1705
+ },
1706
+ ],
1707
+ createdIdByKey: new Map(),
1708
+ };
1709
+ }
1710
+ const inner = execResult.result ?? execResult;
1711
+ const errors = [];
1712
+ const results = inner.results ?? [];
1713
+ const aliasFailures = inner.aliasFailures ?? [];
1714
+ for (const r of results) {
1715
+ if (!r.success)
1716
+ errors.push({ variableId: r.key, error: r.error });
1717
+ }
1718
+ for (const f of aliasFailures) {
1719
+ errors.push({ variableId: f.key, error: f.error });
1720
+ }
1721
+ // Count each variable exactly ONCE: a variable that was created but whose
1722
+ // alias pass failed counts as failed, not as created+failed (the previous
1723
+ // arithmetic double-counted it across both buckets). Alias failures can
1724
+ // repeat per mode — dedupe by variable key.
1725
+ const aliasFailedKeys = new Set(aliasFailures.map((f) => f.key));
1726
+ const createFailed = results.filter((r) => !r.success).length;
1727
+ const createdOk = results.filter((r) => r.success && !aliasFailedKeys.has(r.key)).length;
1728
+ return {
1729
+ created: createdOk,
1730
+ createdCollections: (inner.createdCollections ?? []).length,
1731
+ failed: createFailed + aliasFailedKeys.size,
1732
+ errors,
1733
+ // Alias-failed variables DO exist — keep their IDs resolvable for the
1734
+ // later update phase.
1735
+ createdIdByKey: new Map(Object.entries(inner.createdIds ?? {})),
1736
+ };
1737
+ }
1738
+ // ============================================================================
1739
+ // DELETE PHASE — strategy "replace" only
1740
+ // ============================================================================
1741
+ /**
1742
+ * Delete Figma variables that are absent from the token file. STRICTLY
1743
+ * gated by the caller behind strategy "replace" — merge never reaches this.
1744
+ * Uses the connector's DELETE_VARIABLE bridge command (the same one behind
1745
+ * figma_delete_variable), one call per variable, with per-item error
1746
+ * isolation.
1747
+ */
1748
+ async function applyDeletes(connector, toDelete, figmaDoc) {
1749
+ // Diff key → live Figma variable ID.
1750
+ const figmaIdByKey = new Map();
1751
+ for (const set of figmaDoc.sets) {
1752
+ for (const t of set.tokens) {
1753
+ const id = t.extensions?.["figma-console-mcp"]?.variableId;
1754
+ if (typeof id === "string") {
1755
+ figmaIdByKey.set(`${set.name}::${t.path.join(".")}`, id);
1756
+ }
1757
+ }
1758
+ }
1759
+ let deleted = 0;
1760
+ let failed = 0;
1761
+ const errors = [];
1762
+ for (const entry of toDelete) {
1763
+ const variableId = figmaIdByKey.get(entry.path);
1764
+ if (!variableId) {
1765
+ failed++;
1766
+ errors.push({
1767
+ variableId: entry.path,
1768
+ error: "no Figma variable ID recorded for this token — cannot delete",
1769
+ });
1770
+ continue;
1771
+ }
1772
+ try {
1773
+ const result = await connector.deleteVariable(variableId);
1774
+ if (result && result.success === false) {
1775
+ throw new Error(result.error ?? "delete failed");
1776
+ }
1777
+ deleted++;
1778
+ }
1779
+ catch (err) {
1780
+ failed++;
1781
+ errors.push({
1782
+ variableId,
1783
+ error: err instanceof Error ? err.message : String(err),
1784
+ });
1785
+ }
1786
+ }
1787
+ return { deleted, failed, errors };
1788
+ }