@agent-native/core 0.92.10 → 0.92.12

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 (354) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +29 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +214 -17
  5. package/corpus/core/src/agent/run-manager.ts +72 -14
  6. package/corpus/core/src/agent/run-store.ts +152 -27
  7. package/corpus/core/src/cli/design-connect.ts +433 -151
  8. package/corpus/core/src/cli/skills.ts +14 -0
  9. package/corpus/core/src/client/AssistantChat.tsx +25 -0
  10. package/corpus/core/src/client/MultiTabAssistantChat.tsx +40 -6
  11. package/corpus/core/src/client/agent-chat-adapter.ts +56 -12
  12. package/corpus/core/src/client/agent-chat.ts +165 -1
  13. package/corpus/core/src/client/chat/index.ts +9 -1
  14. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +38 -2
  15. package/corpus/core/src/client/index.ts +5 -0
  16. package/corpus/core/src/client/rich-markdown-editor/useCollabReconcile.ts +18 -7
  17. package/corpus/core/src/collab/awareness-store.ts +23 -12
  18. package/corpus/core/src/collab/client.ts +16 -5
  19. package/corpus/core/src/collab/index.ts +4 -0
  20. package/corpus/core/src/collab/recent-edits.ts +65 -2
  21. package/corpus/core/src/index.browser.ts +1 -0
  22. package/corpus/core/src/server/agent-chat-plugin.ts +98 -11
  23. package/corpus/core/src/server/builder-design-systems.ts +30 -4
  24. package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +1 -1
  25. package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +12 -6
  26. package/corpus/templates/clips/AGENTS.md +4 -0
  27. package/corpus/templates/clips/actions/create-recording.ts +2 -0
  28. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +5 -3
  29. package/corpus/templates/clips/actions/request-transcript.ts +102 -33
  30. package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -0
  31. package/corpus/templates/clips/app/components/player/share-dialog.tsx +59 -19
  32. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +75 -9
  33. package/corpus/templates/clips/app/components/player/video-player.tsx +17 -12
  34. package/corpus/templates/clips/app/hooks/use-player-shortcuts.ts +1 -0
  35. package/corpus/templates/clips/app/i18n/ar-SA.ts +1 -0
  36. package/corpus/templates/clips/app/i18n/de-DE.ts +1 -0
  37. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  38. package/corpus/templates/clips/app/i18n/es-ES.ts +1 -0
  39. package/corpus/templates/clips/app/i18n/fr-FR.ts +1 -0
  40. package/corpus/templates/clips/app/i18n/hi-IN.ts +1 -0
  41. package/corpus/templates/clips/app/i18n/ja-JP.ts +1 -0
  42. package/corpus/templates/clips/app/i18n/ko-KR.ts +1 -0
  43. package/corpus/templates/clips/app/i18n/pt-BR.ts +1 -0
  44. package/corpus/templates/clips/app/i18n/zh-CN.ts +1 -0
  45. package/corpus/templates/clips/app/i18n/zh-TW.ts +1 -0
  46. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +70 -37
  47. package/corpus/templates/clips/app/routes/record.tsx +17 -4
  48. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  49. package/corpus/templates/clips/changelog/2026-07-09-desktop-popover-sits-closer-to-the-menu-bar.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-09-desktop-recording-optimization-progress-stays-visible-until-the-clip-opens.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-09-fixed-large-video-file-uploads-failing-before-their-resumabl.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-09-fixed-new-video-uploads-defaulting-to-private.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-09-long-recordings-now-get-enough-time-to-download-their-media-.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-09-meeting-notes-no-longer-lowers-your-microphone-volume-for-ot.md +6 -0
  55. package/corpus/templates/clips/changelog/2026-07-09-on-mobile-tapping-a-playing-clip-reveals-playback-controls-w.md +6 -0
  56. package/corpus/templates/clips/changelog/2026-07-09-regenerate-any-clip-transcript-from-its-saved-recording-or-r.md +6 -0
  57. package/corpus/templates/clips/changelog/2026-07-09-slack-previews-now-show-the-clip-length-alongside-its-descri.md +6 -0
  58. package/corpus/templates/clips/changelog/2026-07-10-fixed-copying-agent-responses-and-transcript-text-from-recor.md +6 -0
  59. package/corpus/templates/clips/desktop/src/app.tsx +12 -2
  60. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +8 -1
  61. package/corpus/templates/clips/desktop/src/lib/recorder.ts +8 -5
  62. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +5 -5
  63. package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +3 -0
  64. package/corpus/templates/clips/desktop/src/styles.css +6 -3
  65. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +29 -11
  66. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1 -1
  67. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +103 -21
  68. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +117 -39
  69. package/corpus/templates/clips/server/lib/slack-unfurls.ts +20 -1
  70. package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +10 -4
  71. package/corpus/templates/clips/shared/recording-core.ts +12 -0
  72. package/corpus/templates/content/AGENTS.md +7 -6
  73. package/corpus/templates/content/actions/_builder-cms-read-client.ts +82 -7
  74. package/corpus/templates/content/actions/_database-source-utils.ts +149 -30
  75. package/corpus/templates/content/actions/_document-flush.ts +132 -17
  76. package/corpus/templates/content/actions/_notion-action-utils.ts +15 -0
  77. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +4 -7
  78. package/corpus/templates/content/actions/attach-content-database-source.ts +41 -17
  79. package/corpus/templates/content/actions/change-content-database-source-role.ts +46 -8
  80. package/corpus/templates/content/actions/create-and-link-notion-page.ts +2 -0
  81. package/corpus/templates/content/actions/link-notion-page.ts +2 -0
  82. package/corpus/templates/content/actions/pull-document.ts +5 -7
  83. package/corpus/templates/content/actions/pull-notion-page.ts +2 -0
  84. package/corpus/templates/content/actions/push-notion-page.ts +10 -0
  85. package/corpus/templates/content/actions/resolve-notion-sync-conflict.ts +2 -0
  86. package/corpus/templates/content/actions/suggest-source-join-key.ts +8 -1
  87. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +88 -23
  88. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +32 -2
  89. package/corpus/templates/content/app/hooks/use-notion.ts +6 -6
  90. package/corpus/templates/content/app/i18n/zh-TW.ts +1 -0
  91. package/corpus/templates/content/app/i18n-data.ts +18 -0
  92. package/corpus/templates/content/changelog/2026-07-09-builder-source-refreshes-now-keep-mapped-fields-such-as-topi.md +6 -0
  93. package/corpus/templates/content/changelog/2026-07-09-notion-conflict-choices-and-version-restores-preserve-docume.md +6 -0
  94. package/corpus/templates/content/server/lib/notion-sync.ts +80 -64
  95. package/corpus/templates/content/shared/api.ts +1 -0
  96. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +29 -2
  97. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +86 -0
  98. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +57 -0
  99. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +11 -0
  100. package/corpus/templates/design/AGENTS.md +45 -10
  101. package/corpus/templates/design/actions/add-localhost-screens.ts +66 -13
  102. package/corpus/templates/design/actions/apply-component-prop-edit.ts +45 -36
  103. package/corpus/templates/design/actions/apply-motion-edit.ts +25 -31
  104. package/corpus/templates/design/actions/apply-shader-fill.ts +29 -42
  105. package/corpus/templates/design/actions/detach-component-instance.ts +337 -0
  106. package/corpus/templates/design/actions/export-design-as-figma-svg.ts +219 -0
  107. package/corpus/templates/design/actions/generate-design.ts +79 -12
  108. package/corpus/templates/design/actions/generate-screens.ts +57 -12
  109. package/corpus/templates/design/actions/get-figma-styles.ts +115 -0
  110. package/corpus/templates/design/actions/go-to-main-component.ts +254 -0
  111. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +4 -3
  112. package/corpus/templates/design/actions/import-design-source.ts +4 -31
  113. package/corpus/templates/design/actions/import-figma-clipboard.ts +134 -0
  114. package/corpus/templates/design/actions/import-figma-frame.ts +95 -0
  115. package/corpus/templates/design/actions/index-design-system-with-builder.ts +16 -1
  116. package/corpus/templates/design/actions/list-design-components.ts +104 -0
  117. package/corpus/templates/design/actions/list-figma-library-assets.ts +1 -24
  118. package/corpus/templates/design/actions/open-component-source.ts +2 -4
  119. package/corpus/templates/design/actions/present-design-variants.ts +412 -16
  120. package/corpus/templates/design/actions/preview-source-edit.ts +14 -1
  121. package/corpus/templates/design/actions/run-design-audit.ts +25 -3
  122. package/corpus/templates/design/actions/show-design-questions.ts +10 -0
  123. package/corpus/templates/design/actions/swap-component-instance.ts +544 -0
  124. package/corpus/templates/design/actions/take-design-screenshot.ts +45 -60
  125. package/corpus/templates/design/actions/write-local-file.ts +25 -1
  126. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +152 -0
  127. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1077 -58
  128. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +5 -5
  129. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +73 -21
  130. package/corpus/templates/design/app/components/design/EditPanel.tsx +16 -11
  131. package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +364 -0
  132. package/corpus/templates/design/app/components/design/LayersPanel.tsx +180 -53
  133. package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +0 -2
  134. package/corpus/templates/design/app/components/design/MotionDock.tsx +144 -21
  135. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1152 -167
  136. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +27 -5
  137. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +23 -2
  138. package/corpus/templates/design/app/components/design/TokensPanel.tsx +63 -49
  139. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1293 -54
  140. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +7 -0
  141. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +6 -1
  142. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +23 -6
  143. package/corpus/templates/design/app/components/design/bridge/sample.bridge.ts +7 -0
  144. package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +5 -0
  145. package/corpus/templates/design/app/components/design/code-workbench/editor/MonacoHost.tsx +14 -2
  146. package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +37 -2
  147. package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open-guard.ts +25 -0
  148. package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open.ts +19 -1
  149. package/corpus/templates/design/app/components/design/code-workbench/model-registry.ts +43 -5
  150. package/corpus/templates/design/app/components/design/code-workbench/search/SearchView.tsx +27 -2
  151. package/corpus/templates/design/app/components/design/code-workbench/search/search-engine.ts +10 -1
  152. package/corpus/templates/design/app/components/design/code-workbench/store.tsx +19 -2
  153. package/corpus/templates/design/app/components/design/design-canvas/annotation-snapshot.ts +265 -0
  154. package/corpus/templates/design/app/components/design/design-canvas/annotation-submit.ts +16 -5
  155. package/corpus/templates/design/app/components/design/design-canvas/coordinate-transforms.ts +16 -2
  156. package/corpus/templates/design/app/components/design/design-canvas/embedded-frame.ts +7 -2
  157. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +3 -1
  158. package/corpus/templates/design/app/components/design/edit-panel/code-inspect-helpers.tsx +78 -8
  159. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +451 -73
  160. package/corpus/templates/design/app/components/design/edit-panel/document-colors.ts +12 -4
  161. package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +398 -272
  162. package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +41 -7
  163. package/corpus/templates/design/app/components/design/edit-panel/element-identity.ts +16 -3
  164. package/corpus/templates/design/app/components/design/edit-panel/field-primitives.tsx +24 -4
  165. package/corpus/templates/design/app/components/design/edit-panel/fill-gradient-helpers.ts +222 -4
  166. package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +171 -78
  167. package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +45 -5
  168. package/corpus/templates/design/app/components/design/edit-panel/layout-properties.tsx +299 -28
  169. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +120 -150
  170. package/corpus/templates/design/app/components/design/edit-panel/position-helpers.ts +53 -1
  171. package/corpus/templates/design/app/components/design/edit-panel/position-layout-properties.tsx +284 -117
  172. package/corpus/templates/design/app/components/design/edit-panel/selection-helpers.ts +81 -0
  173. package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +19 -14
  174. package/corpus/templates/design/app/components/design/edit-panel/style-options.ts +7 -0
  175. package/corpus/templates/design/app/components/design/edit-panel/typography-helpers.ts +155 -0
  176. package/corpus/templates/design/app/components/design/edit-panel/typography-properties.tsx +268 -46
  177. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +350 -31
  178. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +13 -4
  179. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +233 -23
  180. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +72 -2
  181. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +51 -11
  182. package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +84 -11
  183. package/corpus/templates/design/app/components/design/inspector/index.ts +3 -0
  184. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +552 -0
  185. package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +157 -0
  186. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +53 -0
  187. package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +184 -0
  188. package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +403 -26
  189. package/corpus/templates/design/app/components/design/multi-screen/types.ts +20 -1
  190. package/corpus/templates/design/app/components/design/types.ts +33 -0
  191. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +59 -7
  192. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +119 -17
  193. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +275 -63
  194. package/corpus/templates/design/app/hooks/use-question-flow.ts +14 -3
  195. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +67 -10
  196. package/corpus/templates/design/app/i18n/zh-TW.ts +91 -1
  197. package/corpus/templates/design/app/i18n-data.ts +1217 -10
  198. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +577 -0
  199. package/corpus/templates/design/app/lib/agent-chat.ts +23 -0
  200. package/corpus/templates/design/app/lib/design-import.ts +24 -0
  201. package/corpus/templates/design/app/lib/figma-clipboard.ts +157 -0
  202. package/corpus/templates/design/app/lib/figma-svg-copy.ts +221 -0
  203. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +143 -30
  204. package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +15 -0
  205. package/corpus/templates/design/app/pages/design-editor/editor-session.ts +33 -0
  206. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +38 -0
  207. package/corpus/templates/design/app/pages/design-editor/overview-annotation-context.ts +157 -0
  208. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +62 -6
  209. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +245 -0
  210. package/corpus/templates/design/app/pages/design-editor/react-semantic-handoff.ts +586 -0
  211. package/corpus/templates/design/app/pages/design-editor/screen-auto-layout.ts +169 -0
  212. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +201 -1
  213. package/corpus/templates/design/changelog/2026-07-09-added-underline-strikethrough-and-text-case-uppercase-lowerc.md +6 -0
  214. package/corpus/templates/design/changelog/2026-07-09-annotate-now-draws-across-the-all-screens-canvas-without-swi.md +6 -0
  215. package/corpus/templates/design/changelog/2026-07-09-auto-layout-children-can-now-be-dragged-into-freeform-screen.md +6 -0
  216. package/corpus/templates/design/changelog/2026-07-09-board-shapes-now-stay-visible-and-nest-correctly-across-the-.md +6 -0
  217. package/corpus/templates/design/changelog/2026-07-09-component-source-navigation-now-opens-reliably-from-the-insp.md +6 -0
  218. package/corpus/templates/design/changelog/2026-07-09-cross-screen-local-react-layer-moves-now-hand-off-exact-sour.md +6 -0
  219. package/corpus/templates/design/changelog/2026-07-09-cross-screen-moves-undo-and-redo-now-keep-both-screens-stabl.md +6 -0
  220. package/corpus/templates/design/changelog/2026-07-09-detach-a-component-instance-into-plain-editable-eleme.md +6 -0
  221. package/corpus/templates/design/changelog/2026-07-09-dragging-layers-into-auto-layout-now-respects-ignore-auto-la.md +6 -0
  222. package/corpus/templates/design/changelog/2026-07-09-drawing-annotations-sent-to-the-agent-now-include-a-rendered.md +6 -0
  223. package/corpus/templates/design/changelog/2026-07-09-editor-sidebars-now-use-figma-matched-default-widths-for-a-r.md +6 -0
  224. package/corpus/templates/design/changelog/2026-07-09-empty-frames-and-rectangles-now-expose-full-auto-layout-cont.md +6 -0
  225. package/corpus/templates/design/changelog/2026-07-09-export-any-design-screen-as-a-real-vector-svg-for-figma-with.md +6 -0
  226. package/corpus/templates/design/changelog/2026-07-09-grid-auto-layout-now-uses-true-responsive-rows-and-columns-w.md +6 -0
  227. package/corpus/templates/design/changelog/2026-07-09-hidden-and-locked-screens-now-behave-consistently-between-la.md +6 -0
  228. package/corpus/templates/design/changelog/2026-07-09-image-fills-now-preserve-valid-urls-and-layered-fit-settings.md +6 -0
  229. package/corpus/templates/design/changelog/2026-07-09-import-figma-frames-as-pixel-accurate-design-screens-via-a-s.md +6 -0
  230. package/corpus/templates/design/changelog/2026-07-09-inspect-code-now-hides-internal-styling-metadata-and-wraps-l.md +6 -0
  231. package/corpus/templates/design/changelog/2026-07-09-inspector-blur-and-shadow-effects-now-preserve-other-effects.md +6 -0
  232. package/corpus/templates/design/changelog/2026-07-09-jump-to-a-components-other-instances-from-the-inspecto.md +6 -0
  233. package/corpus/templates/design/changelog/2026-07-09-keyboard-shortcuts-are-now-discoverable-in-a-figma-style-bot.md +6 -0
  234. package/corpus/templates/design/changelog/2026-07-09-layer-selection-now-keeps-cmd-or-ctrl-toggles-and-shift-rang.md +6 -0
  235. package/corpus/templates/design/changelog/2026-07-09-layers-dropped-into-auto-layout-now-participate-correctly-in.md +6 -0
  236. package/corpus/templates/design/changelog/2026-07-09-local-live-edit-screens-now-enter-and-leave-full-view-withou.md +6 -0
  237. package/corpus/templates/design/changelog/2026-07-09-local-preview-urls-now-live-in-the-inspector-instead-of-taki.md +6 -0
  238. package/corpus/templates/design/changelog/2026-07-09-local-react-layers-can-now-be-hidden-and-locked-from-layers-.md +6 -0
  239. package/corpus/templates/design/changelog/2026-07-09-local-react-layers-now-keep-exact-source-provenance-and-supp.md +6 -0
  240. package/corpus/templates/design/changelog/2026-07-09-local-react-visual-editing-now-loads-reliably-and-shows-live.md +6 -0
  241. package/corpus/templates/design/changelog/2026-07-09-paste-from-figma-cmd-v-now-imports-exact-editable-nodes-when.md +6 -0
  242. package/corpus/templates/design/changelog/2026-07-09-pressing-escape-after-selecting-a-screen-now-reveals-page-pr.md +6 -0
  243. package/corpus/templates/design/changelog/2026-07-09-right-click-overlapping-or-nested-objects-to-choose-the-exac.md +6 -0
  244. package/corpus/templates/design/changelog/2026-07-09-scale-edge-stretch-and-center-constraints-now-preserve-layer.md +6 -0
  245. package/corpus/templates/design/changelog/2026-07-09-screen-rows-can-now-be-reordered-in-layers-with-matching-can.md +6 -0
  246. package/corpus/templates/design/changelog/2026-07-09-screen-selection-stays-responsive-in-designs-with-hundreds-o.md +6 -0
  247. package/corpus/templates/design/changelog/2026-07-09-shift-a-now-enables-auto-layout-on-a-selected-screen-with-fl.md +6 -0
  248. package/corpus/templates/design/changelog/2026-07-09-swap-a-component-instance-for-another-from-the-libra.md +6 -0
  249. package/corpus/templates/design/changelog/2026-07-10-drawing-annotations-now-stay-available-to-retry-when-the.md +6 -0
  250. package/corpus/templates/design/e2e/global-setup.ts +3 -6
  251. package/corpus/templates/design/e2e/helpers.ts +27 -6
  252. package/corpus/templates/design/server/lib/design-to-figma-svg.ts +1412 -0
  253. package/corpus/templates/design/server/lib/figma-clipboard-match.ts +196 -0
  254. package/corpus/templates/design/server/lib/figma-node-import.ts +311 -0
  255. package/corpus/templates/design/server/lib/figma-node-to-html.ts +1334 -0
  256. package/corpus/templates/design/server/lib/figma-paste-fallback.ts +64 -0
  257. package/corpus/templates/design/server/lib/playwright-runtime.ts +78 -0
  258. package/corpus/templates/design/server/plugins/db.ts +41 -0
  259. package/corpus/templates/design/server/source-workspace.ts +64 -0
  260. package/corpus/templates/design/shared/board-file.ts +13 -2
  261. package/corpus/templates/design/shared/canvas-math.ts +231 -33
  262. package/corpus/templates/design/shared/code-layer.ts +122 -8
  263. package/corpus/templates/design/shared/component-library.ts +161 -0
  264. package/corpus/templates/design/shared/db-conflict.ts +27 -0
  265. package/corpus/templates/design/shared/design-source-capabilities.ts +2 -1
  266. package/corpus/templates/design/shared/figma-url.ts +149 -0
  267. package/corpus/templates/design/shared/pen-path.ts +16 -7
  268. package/corpus/templates/design/shared/source-mode.ts +1 -0
  269. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +7 -19
  270. package/corpus/templates/plan/changelog/2026-07-09-canvas-panning-now-keeps-zoom-fixed-even-at-the-edges.md +6 -0
  271. package/corpus/templates/slides/actions/index-design-system-with-builder.ts +16 -1
  272. package/corpus/templates/slides/app/context/DeckContext.tsx +311 -70
  273. package/corpus/templates/slides/changelog/2026-07-09-fixed-the-slide-rail-going-permanently-stale-during-a-long-a.md +6 -0
  274. package/dist/agent/production-agent.d.ts +82 -7
  275. package/dist/agent/production-agent.d.ts.map +1 -1
  276. package/dist/agent/production-agent.js +180 -17
  277. package/dist/agent/production-agent.js.map +1 -1
  278. package/dist/agent/run-manager.d.ts.map +1 -1
  279. package/dist/agent/run-manager.js +63 -14
  280. package/dist/agent/run-manager.js.map +1 -1
  281. package/dist/agent/run-store.d.ts +46 -0
  282. package/dist/agent/run-store.d.ts.map +1 -1
  283. package/dist/agent/run-store.js +129 -22
  284. package/dist/agent/run-store.js.map +1 -1
  285. package/dist/cli/design-connect.d.ts +6 -0
  286. package/dist/cli/design-connect.d.ts.map +1 -1
  287. package/dist/cli/design-connect.js +336 -139
  288. package/dist/cli/design-connect.js.map +1 -1
  289. package/dist/cli/skills.d.ts.map +1 -1
  290. package/dist/cli/skills.js +14 -0
  291. package/dist/cli/skills.js.map +1 -1
  292. package/dist/client/AssistantChat.d.ts +2 -0
  293. package/dist/client/AssistantChat.d.ts.map +1 -1
  294. package/dist/client/AssistantChat.js +19 -3
  295. package/dist/client/AssistantChat.js.map +1 -1
  296. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  297. package/dist/client/MultiTabAssistantChat.js +38 -18
  298. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  299. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  300. package/dist/client/agent-chat-adapter.js +48 -12
  301. package/dist/client/agent-chat-adapter.js.map +1 -1
  302. package/dist/client/agent-chat.d.ts +60 -0
  303. package/dist/client/agent-chat.d.ts.map +1 -1
  304. package/dist/client/agent-chat.js +122 -1
  305. package/dist/client/agent-chat.js.map +1 -1
  306. package/dist/client/chat/index.d.ts +1 -1
  307. package/dist/client/chat/index.d.ts.map +1 -1
  308. package/dist/client/chat/index.js +1 -1
  309. package/dist/client/chat/index.js.map +1 -1
  310. package/dist/client/extensions/EmbeddedExtension.d.ts +12 -0
  311. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  312. package/dist/client/extensions/EmbeddedExtension.js +36 -2
  313. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  314. package/dist/client/index.d.ts +1 -1
  315. package/dist/client/index.d.ts.map +1 -1
  316. package/dist/client/index.js +1 -1
  317. package/dist/client/index.js.map +1 -1
  318. package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts.map +1 -1
  319. package/dist/client/rich-markdown-editor/useCollabReconcile.js +18 -6
  320. package/dist/client/rich-markdown-editor/useCollabReconcile.js.map +1 -1
  321. package/dist/collab/awareness-store.d.ts +5 -0
  322. package/dist/collab/awareness-store.d.ts.map +1 -1
  323. package/dist/collab/awareness-store.js +19 -12
  324. package/dist/collab/awareness-store.js.map +1 -1
  325. package/dist/collab/awareness.d.ts +2 -2
  326. package/dist/collab/awareness.d.ts.map +1 -1
  327. package/dist/collab/client.d.ts.map +1 -1
  328. package/dist/collab/client.js +13 -5
  329. package/dist/collab/client.js.map +1 -1
  330. package/dist/collab/index.d.ts +1 -0
  331. package/dist/collab/index.d.ts.map +1 -1
  332. package/dist/collab/index.js +1 -0
  333. package/dist/collab/index.js.map +1 -1
  334. package/dist/collab/recent-edits.d.ts +3 -1
  335. package/dist/collab/recent-edits.d.ts.map +1 -1
  336. package/dist/collab/recent-edits.js +57 -2
  337. package/dist/collab/recent-edits.js.map +1 -1
  338. package/dist/collab/struct-routes.d.ts +1 -1
  339. package/dist/index.browser.d.ts +1 -1
  340. package/dist/index.browser.d.ts.map +1 -1
  341. package/dist/index.browser.js +1 -1
  342. package/dist/index.browser.js.map +1 -1
  343. package/dist/observability/routes.d.ts +5 -5
  344. package/dist/progress/routes.d.ts +1 -1
  345. package/dist/resources/handlers.d.ts +3 -3
  346. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  347. package/dist/server/agent-chat-plugin.js +87 -10
  348. package/dist/server/agent-chat-plugin.js.map +1 -1
  349. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  350. package/dist/server/builder-design-systems.d.ts +15 -0
  351. package/dist/server/builder-design-systems.d.ts.map +1 -1
  352. package/dist/server/builder-design-systems.js +9 -4
  353. package/dist/server/builder-design-systems.js.map +1 -1
  354. package/package.json +1 -1
@@ -0,0 +1,1334 @@
1
+ /**
2
+ * Figma node JSON -> HTML mapper.
3
+ *
4
+ * Input is the `document` subtree returned by
5
+ * `GET /v1/files/:fileKey/nodes?ids=...&geometry=paths` (or the file's root
6
+ * `document` node). Output is a self-contained HTML fragment using absolute
7
+ * positioning + inline styles, matching Figma's own canvas model 1:1 rather
8
+ * than reconstructing a semantic/Tailwind layout — the goal is pixel fidelity
9
+ * for an imported snapshot, not idiomatic hand-authored markup.
10
+ *
11
+ * This module is pure and synchronous: it never calls the network. The
12
+ * caller (an action) is responsible for:
13
+ * 1. Fetching the node JSON from the Figma REST API.
14
+ * 2. Calling `collectFallbackNodeIds` / `collectImageFillRefs` to find out
15
+ * which nodes need a rendered PNG fallback and which image fills need
16
+ * resolved URLs.
17
+ * 3. Fetching those via `/v1/images/:fileKey` (fallback renders) and
18
+ * `/v1/files/:fileKey/images` (fill ref -> URL map).
19
+ * 4. Calling `mapFigmaNodeToHtml` with the resulting maps.
20
+ *
21
+ * ## Pixel-perfect property coverage
22
+ *
23
+ * | Property | Fidelity | Notes |
24
+ * | ------------------------------------------- | ------------- | ----- |
25
+ * | Position/size (absoluteBoundingBox) | exact | frame-relative |
26
+ * | Auto-layout (flex*) | exact | Figma auto-layout IS flexbox |
27
+ * | Text font/size/weight/case/decoration/align | exact | |
28
+ * | Line-height (px vs percent-of-font-size) | exact | resolved to px |
29
+ * | Letter-spacing | exact | already px in REST API |
30
+ * | Solid fills | exact | |
31
+ * | Gradient fills (angle/position) | exact (linear)/approximated (radial/angular/diamond) | derived from gradientHandlePositions, not a default angle |
32
+ * | Multiple fills (layering) | exact | reversed to match CSS background-image stacking |
33
+ * | Image fills (scale modes) | exact (FILL/FIT/TILE/STRETCH-axis-aligned) / approximated (skewed imageTransform) | |
34
+ * | Strokes (uniform, align) | exact | CENTER via outline+negative offset, INSIDE via inset box-shadow, OUTSIDE via outline |
35
+ * | Strokes (per-side weights) | approximated | CSS has no per-side outline; falls back to per-side `border` (inside-only) |
36
+ * | Corner radii (uniform + per-corner) | exact | |
37
+ * | Effects: drop/inner shadow | exact | |
38
+ * | Effects: layer/background blur | approximated | Figma <-> CSS blur radius scale is not publicly specified as 1:1 |
39
+ * | Opacity | exact | |
40
+ * | Blend modes (CSS-supported) | exact | |
41
+ * | Blend modes (Figma-only: LINEAR_BURN/DODGE/LIGHTER/DARKER) | approximated | mapped to closest CSS equivalent |
42
+ * | clipsContent | exact | overflow: hidden |
43
+ * | Rotation | approximated | pivots about the bounding-box center (see below) |
44
+ * | Vector networks / boolean ops / unsupported types | image fallback | never approximated structurally |
45
+ *
46
+ * ### Rotation caveat
47
+ * The REST API's `rotation` field is in degrees (-180..180), and
48
+ * `absoluteBoundingBox` is the *already-rotated* axis-aligned bounding box —
49
+ * Figma does not expose the pre-rotation box directly. We reconstruct the
50
+ * unrotated box by treating the AABB's center as invariant under rotation
51
+ * (true for a shape rotated about its own center) and rotate the CSS element
52
+ * about `transform-origin: center` by `-rotation` degrees (Figma's rotation
53
+ * is clockwise-negative relative to CSS's clockwise-positive convention).
54
+ * This is exact when Figma pivots rotation about the shape's center and only
55
+ * approximated if Figma's internal pivot differs (rare in practice; visually
56
+ * indistinguishable in the overwhelming majority of designs). A fully exact
57
+ * alternative would consume `relativeTransform` as a CSS `matrix()` directly,
58
+ * which is a documented follow-up if a specific design surfaces a visible
59
+ * mismatch.
60
+ */
61
+
62
+ export interface FigmaColor {
63
+ r: number;
64
+ g: number;
65
+ b: number;
66
+ a?: number;
67
+ }
68
+
69
+ export interface FigmaColorStop {
70
+ position: number;
71
+ color: FigmaColor;
72
+ }
73
+
74
+ export interface FigmaImageFilter {
75
+ exposure?: number;
76
+ contrast?: number;
77
+ saturation?: number;
78
+ temperature?: number;
79
+ tint?: number;
80
+ highlights?: number;
81
+ shadows?: number;
82
+ }
83
+
84
+ export interface FigmaPaint {
85
+ type:
86
+ | "SOLID"
87
+ | "GRADIENT_LINEAR"
88
+ | "GRADIENT_RADIAL"
89
+ | "GRADIENT_ANGULAR"
90
+ | "GRADIENT_DIAMOND"
91
+ | "IMAGE"
92
+ | "EMOJI"
93
+ | "VIDEO";
94
+ visible?: boolean;
95
+ opacity?: number;
96
+ color?: FigmaColor;
97
+ gradientHandlePositions?: Array<{ x: number; y: number }>;
98
+ gradientStops?: FigmaColorStop[];
99
+ imageRef?: string;
100
+ scaleMode?: "FILL" | "FIT" | "TILE" | "STRETCH";
101
+ imageTransform?: [[number, number, number], [number, number, number]];
102
+ filters?: FigmaImageFilter;
103
+ }
104
+
105
+ export interface FigmaEffect {
106
+ type: "DROP_SHADOW" | "INNER_SHADOW" | "LAYER_BLUR" | "BACKGROUND_BLUR";
107
+ visible?: boolean;
108
+ radius?: number;
109
+ spread?: number;
110
+ color?: FigmaColor;
111
+ offset?: { x: number; y: number };
112
+ blendMode?: string;
113
+ }
114
+
115
+ export interface FigmaTypeStyle {
116
+ fontFamily?: string;
117
+ fontPostScriptName?: string;
118
+ fontWeight?: number;
119
+ fontSize?: number;
120
+ italic?: boolean;
121
+ letterSpacing?: number;
122
+ lineHeightPx?: number;
123
+ lineHeightPercent?: number;
124
+ lineHeightPercentFontSize?: number;
125
+ lineHeightUnit?: "PIXELS" | "FONT_SIZE_%" | "INTRINSIC_%";
126
+ textCase?: "ORIGINAL" | "UPPER" | "LOWER" | "TITLE";
127
+ textDecoration?: "NONE" | "UNDERLINE" | "STRIKETHROUGH";
128
+ textAlignHorizontal?: "LEFT" | "RIGHT" | "CENTER" | "JUSTIFIED";
129
+ textAlignVertical?: "TOP" | "CENTER" | "BOTTOM";
130
+ textAutoResize?: "NONE" | "WIDTH_AND_HEIGHT" | "HEIGHT" | "TRUNCATE";
131
+ fills?: FigmaPaint[];
132
+ }
133
+
134
+ export interface FigmaIndividualStrokeWeights {
135
+ top?: number;
136
+ right?: number;
137
+ bottom?: number;
138
+ left?: number;
139
+ }
140
+
141
+ export interface FigmaBoundingBox {
142
+ x: number;
143
+ y: number;
144
+ width: number;
145
+ height: number;
146
+ }
147
+
148
+ export interface FigmaNode {
149
+ id: string;
150
+ name?: string;
151
+ type: string;
152
+ visible?: boolean;
153
+ opacity?: number;
154
+ blendMode?: string;
155
+ rotation?: number;
156
+ absoluteBoundingBox?: FigmaBoundingBox;
157
+ size?: { x: number; y: number };
158
+ clipsContent?: boolean;
159
+ characters?: string;
160
+ style?: FigmaTypeStyle;
161
+ fills?: FigmaPaint[];
162
+ strokes?: FigmaPaint[];
163
+ strokeWeight?: number;
164
+ strokeAlign?: "INSIDE" | "OUTSIDE" | "CENTER";
165
+ individualStrokeWeights?: FigmaIndividualStrokeWeights;
166
+ cornerRadius?: number;
167
+ rectangleCornerRadii?: [number, number, number, number];
168
+ effects?: FigmaEffect[];
169
+ layoutMode?: "NONE" | "HORIZONTAL" | "VERTICAL" | "GRID";
170
+ primaryAxisAlignItems?: "MIN" | "CENTER" | "MAX" | "SPACE_BETWEEN";
171
+ counterAxisAlignItems?: "MIN" | "CENTER" | "MAX" | "BASELINE";
172
+ layoutSizingHorizontal?: "FIXED" | "HUG" | "FILL";
173
+ layoutSizingVertical?: "FIXED" | "HUG" | "FILL";
174
+ layoutWrap?: "NO_WRAP" | "WRAP";
175
+ itemSpacing?: number;
176
+ counterAxisSpacing?: number;
177
+ paddingLeft?: number;
178
+ paddingRight?: number;
179
+ paddingTop?: number;
180
+ paddingBottom?: number;
181
+ children?: FigmaNode[];
182
+ }
183
+
184
+ export type FidelityLevel = "exact" | "approximated" | "image-fallback";
185
+
186
+ export interface FidelityEntry {
187
+ nodeId: string;
188
+ nodeName: string;
189
+ nodeType: string;
190
+ level: FidelityLevel;
191
+ notes: string[];
192
+ }
193
+
194
+ export interface FidelityReport {
195
+ entries: FidelityEntry[];
196
+ summary: {
197
+ exact: number;
198
+ approximated: number;
199
+ imageFallback: number;
200
+ };
201
+ }
202
+
203
+ export interface MapFigmaNodeOptions {
204
+ /** imageRef hash -> resolved public URL, from `/v1/files/:key/images`. */
205
+ imageFillUrls?: Record<string, string>;
206
+ /** nodeId -> rendered PNG URL, from `/v1/images/:key` for fallback subtrees. */
207
+ fallbackImageUrls?: Record<string, string>;
208
+ /** Node ids that should be rendered as an image regardless of type. */
209
+ forceImageFallbackNodeIds?: Set<string>;
210
+ }
211
+
212
+ export interface MapFigmaNodeResult {
213
+ html: string;
214
+ fidelity: FidelityReport;
215
+ }
216
+
217
+ const UNSUPPORTED_STRUCTURAL_TYPES = new Set([
218
+ "BOOLEAN_OPERATION",
219
+ "VECTOR",
220
+ "STAR",
221
+ "REGULAR_POLYGON",
222
+ "SLICE",
223
+ "STICKY",
224
+ "SHAPE_WITH_TEXT",
225
+ "CONNECTOR",
226
+ "WASHI_TAPE",
227
+ "TABLE",
228
+ ]);
229
+
230
+ const SUPPORTED_CONTAINER_TYPES = new Set([
231
+ "FRAME",
232
+ "GROUP",
233
+ "COMPONENT",
234
+ "COMPONENT_SET",
235
+ "INSTANCE",
236
+ "SECTION",
237
+ ]);
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Formatting helpers
241
+ // ---------------------------------------------------------------------------
242
+
243
+ function round(value: number, precision = 2): number {
244
+ const factor = 10 ** precision;
245
+ return Math.round(value * factor) / factor;
246
+ }
247
+
248
+ function px(value: number | undefined, precision = 2): string | undefined {
249
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
250
+ return `${round(value, precision)}px`;
251
+ }
252
+
253
+ function colorToCss(
254
+ color: FigmaColor | undefined,
255
+ opacityMul = 1,
256
+ ): string | null {
257
+ if (!color) return null;
258
+ const r = Math.round((color.r ?? 0) * 255);
259
+ const g = Math.round((color.g ?? 0) * 255);
260
+ const b = Math.round((color.b ?? 0) * 255);
261
+ const a = round((color.a ?? 1) * opacityMul, 4);
262
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
263
+ }
264
+
265
+ function escapeHtml(value: string): string {
266
+ return value
267
+ .replace(/&/g, "&amp;")
268
+ .replace(/</g, "&lt;")
269
+ .replace(/>/g, "&gt;")
270
+ .replace(/"/g, "&quot;");
271
+ }
272
+
273
+ function escapeAttr(value: string): string {
274
+ return escapeHtml(value);
275
+ }
276
+
277
+ /**
278
+ * Builds the CSS text for a `style="..."` attribute AND escapes it for HTML
279
+ * attribute context. This matters because at least one style value we emit
280
+ * legitimately contains a literal double-quote character: font-family values
281
+ * are built as `"Inter", sans-serif` (CSS requires quoting family names with
282
+ * spaces). Without escaping here, that embedded `"` prematurely terminates
283
+ * the enclosing `style="..."` attribute the moment a browser (or any other
284
+ * HTML parser) reads it -- silently dropping every style declared after
285
+ * font-family in object-key order (font-size, font-weight, line-height,
286
+ * text-align, and the text node's own `display: flex` used to emulate
287
+ * vertical alignment). The visible symptom is text rendering at the
288
+ * browser's default font/size instead of the mapped Figma typography, with
289
+ * no error anywhere -- caught here via a real headless-browser render that
290
+ * showed a Figma TEXT node's own style attribute silently truncated at
291
+ * `font-family: "`.
292
+ */
293
+ function styleAttr(styles: Record<string, string | undefined>): string {
294
+ const parts = Object.entries(styles)
295
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string")
296
+ .map(([key, value]) => `${key}: ${value}`);
297
+ return escapeAttr(parts.join("; "));
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Fidelity report builder
302
+ // ---------------------------------------------------------------------------
303
+
304
+ class FidelityTracker {
305
+ private entries = new Map<string, FidelityEntry>();
306
+
307
+ record(node: FigmaNode, level: FidelityLevel, note: string) {
308
+ const existing = this.entries.get(node.id);
309
+ if (existing) {
310
+ // Never downgrade an image-fallback entry, and never upgrade below the
311
+ // worst level recorded for this node.
312
+ const rank: Record<FidelityLevel, number> = {
313
+ exact: 0,
314
+ approximated: 1,
315
+ "image-fallback": 2,
316
+ };
317
+ if (rank[level] > rank[existing.level]) existing.level = level;
318
+ existing.notes.push(note);
319
+ return;
320
+ }
321
+ this.entries.set(node.id, {
322
+ nodeId: node.id,
323
+ nodeName: node.name ?? node.id,
324
+ nodeType: node.type,
325
+ level,
326
+ notes: [note],
327
+ });
328
+ }
329
+
330
+ build(): FidelityReport {
331
+ const entries = [...this.entries.values()];
332
+ const summary = entries.reduce(
333
+ (acc, entry) => {
334
+ if (entry.level === "exact") acc.exact += 1;
335
+ else if (entry.level === "approximated") acc.approximated += 1;
336
+ else acc.imageFallback += 1;
337
+ return acc;
338
+ },
339
+ { exact: 0, approximated: 0, imageFallback: 0 },
340
+ );
341
+ return { entries, summary };
342
+ }
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Gradient angle / position derivation
347
+ // ---------------------------------------------------------------------------
348
+
349
+ interface GradientGeometry {
350
+ start: { x: number; y: number };
351
+ end: { x: number; y: number };
352
+ width: { x: number; y: number };
353
+ }
354
+
355
+ function resolveGradientGeometry(paint: FigmaPaint): GradientGeometry | null {
356
+ const handles = paint.gradientHandlePositions;
357
+ if (!handles || handles.length < 3) return null;
358
+ return { start: handles[0]!, end: handles[1]!, width: handles[2]! };
359
+ }
360
+
361
+ /**
362
+ * Derive a CSS `linear-gradient()` angle (degrees) from Figma's normalized
363
+ * `gradientHandlePositions`. Handle positions are normalized independently in
364
+ * x and y (0..1 relative to the node's bounding box), so the angle must be
365
+ * computed in actual pixel space using the node's real width/height —
366
+ * otherwise a non-square box silently distorts the angle.
367
+ *
368
+ * Verified against Figma's documented identity handles
369
+ * (start=(0,0.5), end=(1,0.5), width=(1,0), i.e. a plain left-to-right
370
+ * gradient) which must resolve to CSS `90deg` ("to right"):
371
+ * dx = 1*w, dy = 0 -> atan2(0, dx) = 0deg -> +90 = 90deg. Matches.
372
+ * And a top-to-bottom gradient (start=(0.5,0), end=(0.5,1)) must resolve to
373
+ * CSS `180deg` ("to bottom"):
374
+ * dx = 0, dy = 1*h -> atan2(dy, 0) = 90deg -> +90 = 180deg. Matches.
375
+ */
376
+ export function gradientAngleDegrees(
377
+ paint: FigmaPaint,
378
+ box: { width: number; height: number },
379
+ ): number | null {
380
+ const geometry = resolveGradientGeometry(paint);
381
+ if (!geometry) return null;
382
+ const dx = (geometry.end.x - geometry.start.x) * box.width;
383
+ const dy = (geometry.end.y - geometry.start.y) * box.height;
384
+ const angleRad = Math.atan2(dy, dx);
385
+ const angleDeg = (angleRad * 180) / Math.PI + 90;
386
+ return ((angleDeg % 360) + 360) % 360;
387
+ }
388
+
389
+ function gradientStopsCss(paint: FigmaPaint): string {
390
+ const stops = paint.gradientStops ?? [];
391
+ return stops
392
+ .map((stop) => {
393
+ const color = colorToCss(stop.color, paint.opacity ?? 1) ?? "transparent";
394
+ return `${color} ${round(stop.position * 100, 2)}%`;
395
+ })
396
+ .join(", ");
397
+ }
398
+
399
+ function vectorLength(
400
+ from: { x: number; y: number },
401
+ to: { x: number; y: number },
402
+ box: { width: number; height: number },
403
+ ): number {
404
+ const dx = (to.x - from.x) * box.width;
405
+ const dy = (to.y - from.y) * box.height;
406
+ return Math.sqrt(dx * dx + dy * dy);
407
+ }
408
+
409
+ /**
410
+ * Convert one Figma paint layer to a CSS `background-image` value (or plain
411
+ * color for a SOLID paint used standalone). Returns null for paints that
412
+ * cannot be expressed as a background-image (handled elsewhere).
413
+ */
414
+ function paintToCssImage(
415
+ paint: FigmaPaint,
416
+ box: { width: number; height: number },
417
+ tracker: FidelityTracker,
418
+ node: FigmaNode,
419
+ ): string | null {
420
+ if (paint.visible === false) return null;
421
+ const stops = gradientStopsCss(paint);
422
+ if (!stops) return null;
423
+
424
+ switch (paint.type) {
425
+ case "GRADIENT_LINEAR": {
426
+ const angle = gradientAngleDegrees(paint, box);
427
+ tracker.record(
428
+ node,
429
+ "exact",
430
+ "Linear gradient angle derived from gradientHandlePositions.",
431
+ );
432
+ return `linear-gradient(${round(angle ?? 90, 2)}deg, ${stops})`;
433
+ }
434
+ case "GRADIENT_RADIAL": {
435
+ const geometry = resolveGradientGeometry(paint);
436
+ if (!geometry) return `radial-gradient(${stops})`;
437
+ const cx = round(geometry.start.x * 100, 2);
438
+ const cy = round(geometry.start.y * 100, 2);
439
+ const radiusX = vectorLength(geometry.start, geometry.width, box);
440
+ const radiusY = vectorLength(geometry.start, geometry.end, box);
441
+ tracker.record(
442
+ node,
443
+ "approximated",
444
+ "Radial gradient rendered as an axis-aligned ellipse sized from gradientHandlePositions; rotated/skewed radial gradients are not expressible in CSS radial-gradient().",
445
+ );
446
+ return `radial-gradient(ellipse ${round(radiusX, 2)}px ${round(radiusY, 2)}px at ${cx}% ${cy}%, ${stops})`;
447
+ }
448
+ case "GRADIENT_ANGULAR": {
449
+ const geometry = resolveGradientGeometry(paint);
450
+ const cx = geometry ? round(geometry.start.x * 100, 2) : 50;
451
+ const cy = geometry ? round(geometry.start.y * 100, 2) : 50;
452
+ const fromAngle = geometry ? gradientAngleDegrees(paint, box) : 0;
453
+ tracker.record(
454
+ node,
455
+ "approximated",
456
+ "Conic (angular) gradient start angle derived from gradientHandlePositions using the same angle formula as linear gradients; CSS conic-gradient() has no elliptical distortion so non-uniform boxes may render slightly differently than Figma.",
457
+ );
458
+ return `conic-gradient(from ${round(fromAngle ?? 0, 2)}deg at ${cx}% ${cy}%, ${stops})`;
459
+ }
460
+ case "GRADIENT_DIAMOND": {
461
+ const geometry = resolveGradientGeometry(paint);
462
+ const cx = geometry ? round(geometry.start.x * 100, 2) : 50;
463
+ const cy = geometry ? round(geometry.start.y * 100, 2) : 50;
464
+ const radiusX = geometry
465
+ ? vectorLength(geometry.start, geometry.width, box)
466
+ : box.width / 2;
467
+ const radiusY = geometry
468
+ ? vectorLength(geometry.start, geometry.end, box)
469
+ : box.height / 2;
470
+ tracker.record(
471
+ node,
472
+ "approximated",
473
+ "Diamond gradient has no CSS equivalent; approximated as an axis-aligned elliptical radial-gradient sized from gradientHandlePositions. True diamond (rotated-square) falloff is not reproduced.",
474
+ );
475
+ return `radial-gradient(ellipse ${round(radiusX, 2)}px ${round(radiusY, 2)}px at ${cx}% ${cy}%, ${stops})`;
476
+ }
477
+ default:
478
+ return null;
479
+ }
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Fills -> background
484
+ // ---------------------------------------------------------------------------
485
+
486
+ interface BackgroundResult {
487
+ backgroundColor?: string;
488
+ backgroundImage?: string;
489
+ backgroundSize?: string;
490
+ backgroundPosition?: string;
491
+ backgroundRepeat?: string;
492
+ color?: string; // for TEXT nodes, fill paints color the glyphs, not a background
493
+ }
494
+
495
+ function imageScaleModeCss(
496
+ paint: FigmaPaint,
497
+ node: FigmaNode,
498
+ tracker: FidelityTracker,
499
+ ): { size: string; position: string; repeat: string } {
500
+ const transform = paint.imageTransform;
501
+ const isAxisAligned =
502
+ !transform ||
503
+ (Math.abs(transform[0][1]) < 1e-6 && Math.abs(transform[1][0]) < 1e-6);
504
+ if (!isAxisAligned) {
505
+ tracker.record(
506
+ node,
507
+ "approximated",
508
+ "Image fill has a non-axis-aligned imageTransform (rotated/skewed crop); approximated using the scale-mode-only CSS mapping without the transform matrix.",
509
+ );
510
+ }
511
+ switch (paint.scaleMode) {
512
+ case "FILL":
513
+ return { size: "cover", position: "center", repeat: "no-repeat" };
514
+ case "FIT":
515
+ return { size: "contain", position: "center", repeat: "no-repeat" };
516
+ case "TILE":
517
+ return { size: "auto", position: "top left", repeat: "repeat" };
518
+ case "STRETCH":
519
+ return { size: "100% 100%", position: "center", repeat: "no-repeat" };
520
+ default:
521
+ return { size: "cover", position: "center", repeat: "no-repeat" };
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Build the background-* properties for a node's fill stack. Figma paints
527
+ * fills bottom-to-top (index 0 is the bottommost layer); CSS
528
+ * `background-image` layers top-to-bottom (first value on top), so the
529
+ * stack is reversed here to preserve visual order. A solid fill above other
530
+ * layers is expressed as a flat `linear-gradient(color, color)` since CSS
531
+ * `background-color` always paints *beneath every* background-image and
532
+ * cannot be interleaved mid-stack.
533
+ */
534
+ function buildFills(
535
+ node: FigmaNode,
536
+ fills: FigmaPaint[] | undefined,
537
+ box: { width: number; height: number },
538
+ options: MapFigmaNodeOptions,
539
+ tracker: FidelityTracker,
540
+ isTextNode: boolean,
541
+ ): BackgroundResult {
542
+ const visible = (fills ?? []).filter((fill) => fill.visible !== false);
543
+ if (visible.length === 0) return {};
544
+
545
+ if (isTextNode) {
546
+ // Text color comes from the topmost visible SOLID fill; gradient/image
547
+ // text fills are a CSS `background-clip: text` trick we intentionally
548
+ // skip for now (rare in practice) and record as approximated.
549
+ const solid = [...visible].reverse().find((fill) => fill.type === "SOLID");
550
+ if (solid) {
551
+ return {
552
+ color: colorToCss(solid.color, solid.opacity ?? 1) ?? undefined,
553
+ };
554
+ }
555
+ tracker.record(
556
+ node,
557
+ "approximated",
558
+ "Text fill is a gradient/image, not a solid color; rendered with the default text color instead of a background-clip: text gradient.",
559
+ );
560
+ return {};
561
+ }
562
+
563
+ const images: string[] = [];
564
+ const sizes: string[] = [];
565
+ const positions: string[] = [];
566
+ const repeats: string[] = [];
567
+ let backgroundColor: string | undefined;
568
+
569
+ // Reverse so the topmost Figma fill becomes the first (topmost) CSS layer.
570
+ const ordered = [...visible].reverse();
571
+ for (let index = 0; index < ordered.length; index += 1) {
572
+ const fill = ordered[index]!;
573
+ const isBottommost = index === ordered.length - 1;
574
+
575
+ if (fill.type === "SOLID") {
576
+ const color = colorToCss(fill.color, fill.opacity ?? 1);
577
+ if (!color) continue;
578
+ if (isBottommost) {
579
+ // A bottom-most solid always paints beneath every background-image
580
+ // layer, so it can always become plain backgroundColor regardless of
581
+ // how many gradient/image layers are stacked above it.
582
+ backgroundColor = color;
583
+ } else {
584
+ // Solid above other layers: express as a flat gradient so it stacks
585
+ // in the correct z-order alongside gradient/image layers.
586
+ images.push(`linear-gradient(${color}, ${color})`);
587
+ sizes.push("100% 100%");
588
+ positions.push("center");
589
+ repeats.push("no-repeat");
590
+ }
591
+ continue;
592
+ }
593
+
594
+ if (fill.type === "IMAGE") {
595
+ const url = fill.imageRef
596
+ ? options.imageFillUrls?.[fill.imageRef]
597
+ : undefined;
598
+ if (!url) {
599
+ tracker.record(
600
+ node,
601
+ "approximated",
602
+ `Image fill imageRef "${fill.imageRef ?? "unknown"}" had no resolved URL; layer omitted.`,
603
+ );
604
+ continue;
605
+ }
606
+ const mode = imageScaleModeCss(fill, node, tracker);
607
+ images.push(`url("${url}")`);
608
+ sizes.push(mode.size);
609
+ positions.push(mode.position);
610
+ repeats.push(mode.repeat);
611
+ continue;
612
+ }
613
+
614
+ const cssImage = paintToCssImage(fill, box, tracker, node);
615
+ if (cssImage) {
616
+ images.push(cssImage);
617
+ sizes.push("100% 100%");
618
+ positions.push("center");
619
+ repeats.push("no-repeat");
620
+ }
621
+ }
622
+
623
+ const result: BackgroundResult = {};
624
+ if (backgroundColor) result.backgroundColor = backgroundColor;
625
+ if (images.length > 0) {
626
+ result.backgroundImage = images.join(", ");
627
+ result.backgroundSize = sizes.join(", ");
628
+ result.backgroundPosition = positions.join(", ");
629
+ result.backgroundRepeat = repeats.join(", ");
630
+ }
631
+ return result;
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Strokes -> border / outline / box-shadow
636
+ // ---------------------------------------------------------------------------
637
+
638
+ interface StrokeResult {
639
+ styles: Record<string, string | undefined>;
640
+ insetShadow?: string;
641
+ }
642
+
643
+ function buildStrokes(node: FigmaNode, tracker: FidelityTracker): StrokeResult {
644
+ const strokes = (node.strokes ?? []).filter(
645
+ (stroke) => stroke.visible !== false,
646
+ );
647
+ if (strokes.length === 0) return { styles: {} };
648
+
649
+ const first = strokes[0]!;
650
+ const color =
651
+ colorToCss(first.color, first.opacity ?? 1) ?? "rgba(0, 0, 0, 1)";
652
+ const iw = node.individualStrokeWeights;
653
+ const hasPerSide =
654
+ iw &&
655
+ (iw.top !== undefined ||
656
+ iw.right !== undefined ||
657
+ iw.bottom !== undefined ||
658
+ iw.left !== undefined);
659
+ const uniformWeight = node.strokeWeight ?? 0;
660
+
661
+ if (hasPerSide) {
662
+ // CSS `outline`/inset-`box-shadow` tricks are single-weight only; per-side
663
+ // stroke weights can only be expressed as a real per-side `border`, which
664
+ // always renders fully inside the border-box regardless of strokeAlign.
665
+ // This is exact for INSIDE and an approximation for CENTER/OUTSIDE.
666
+ const top = iw?.top ?? uniformWeight;
667
+ const right = iw?.right ?? uniformWeight;
668
+ const bottom = iw?.bottom ?? uniformWeight;
669
+ const left = iw?.left ?? uniformWeight;
670
+ tracker.record(
671
+ node,
672
+ node.strokeAlign === "INSIDE" || !node.strokeAlign
673
+ ? "exact"
674
+ : "approximated",
675
+ `Per-side stroke weights rendered as CSS border (inside-aligned); strokeAlign="${node.strokeAlign ?? "INSIDE"}" cannot vary per-side with outline tricks.`,
676
+ );
677
+ return {
678
+ styles: {
679
+ "border-top": top ? `${px(top)} solid ${color}` : undefined,
680
+ "border-right": right ? `${px(right)} solid ${color}` : undefined,
681
+ "border-bottom": bottom ? `${px(bottom)} solid ${color}` : undefined,
682
+ "border-left": left ? `${px(left)} solid ${color}` : undefined,
683
+ },
684
+ };
685
+ }
686
+
687
+ if (!uniformWeight) return { styles: {} };
688
+
689
+ switch (node.strokeAlign) {
690
+ case "OUTSIDE":
691
+ tracker.record(
692
+ node,
693
+ "exact",
694
+ "OUTSIDE stroke rendered via outline (offset 0).",
695
+ );
696
+ return {
697
+ styles: {
698
+ outline: `${px(uniformWeight)} solid ${color}`,
699
+ "outline-offset": "0px",
700
+ },
701
+ };
702
+ case "INSIDE":
703
+ tracker.record(
704
+ node,
705
+ "exact",
706
+ "INSIDE stroke rendered via inset box-shadow.",
707
+ );
708
+ return {
709
+ styles: {},
710
+ insetShadow: `inset 0 0 0 ${px(uniformWeight)} ${color}`,
711
+ };
712
+ case "CENTER":
713
+ default:
714
+ // outline-offset of -half the weight pulls the outline half inside,
715
+ // half outside the border-box edge -- reproducing Figma's CENTER
716
+ // straddle exactly (plain CSS `border` cannot straddle the edge).
717
+ tracker.record(
718
+ node,
719
+ "exact",
720
+ "CENTER stroke rendered via outline with outline-offset = -weight/2 (straddles the edge like Figma).",
721
+ );
722
+ return {
723
+ styles: {
724
+ outline: `${px(uniformWeight)} solid ${color}`,
725
+ "outline-offset": px(-uniformWeight / 2),
726
+ },
727
+ };
728
+ }
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // Corner radii
733
+ // ---------------------------------------------------------------------------
734
+
735
+ function buildCornerRadius(node: FigmaNode): string | undefined {
736
+ if (node.rectangleCornerRadii) {
737
+ const [tl, tr, br, bl] = node.rectangleCornerRadii;
738
+ return `${px(tl) ?? "0px"} ${px(tr) ?? "0px"} ${px(br) ?? "0px"} ${px(bl) ?? "0px"}`;
739
+ }
740
+ if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
741
+ return px(node.cornerRadius);
742
+ }
743
+ return undefined;
744
+ }
745
+
746
+ // ---------------------------------------------------------------------------
747
+ // Effects -> box-shadow / filter / backdrop-filter
748
+ // ---------------------------------------------------------------------------
749
+
750
+ interface EffectResult {
751
+ boxShadowLayers: string[];
752
+ filter?: string;
753
+ backdropFilter?: string;
754
+ }
755
+
756
+ function buildEffects(
757
+ node: FigmaNode,
758
+ isTextNode: boolean,
759
+ tracker: FidelityTracker,
760
+ ): EffectResult {
761
+ const effects = (node.effects ?? []).filter(
762
+ (effect) => effect.visible !== false,
763
+ );
764
+ const boxShadowLayers: string[] = [];
765
+ let filter: string | undefined;
766
+ let backdropFilter: string | undefined;
767
+
768
+ for (const effect of effects) {
769
+ if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") {
770
+ const color = colorToCss(effect.color, 1) ?? "rgba(0, 0, 0, 1)";
771
+ const x = px(effect.offset?.x ?? 0) ?? "0px";
772
+ const y = px(effect.offset?.y ?? 0) ?? "0px";
773
+ const blur = px(effect.radius ?? 0) ?? "0px";
774
+ const spread =
775
+ !isTextNode && typeof effect.spread === "number"
776
+ ? ` ${px(effect.spread)}`
777
+ : "";
778
+ const inset = effect.type === "INNER_SHADOW" ? "inset " : "";
779
+ boxShadowLayers.push(`${inset}${x} ${y} ${blur}${spread} ${color}`);
780
+ tracker.record(
781
+ node,
782
+ "exact",
783
+ `${effect.type} rendered as ${isTextNode ? "text-shadow" : "box-shadow"}.`,
784
+ );
785
+ } else if (effect.type === "LAYER_BLUR") {
786
+ const radius = px(effect.radius ?? 0) ?? "0px";
787
+ filter = filter ? `${filter} blur(${radius})` : `blur(${radius})`;
788
+ tracker.record(
789
+ node,
790
+ "approximated",
791
+ "LAYER_BLUR mapped 1:1 to CSS filter: blur(); Figma's blur radius-to-CSS-stdDeviation scale is not publicly documented, so the rendered softness may differ slightly.",
792
+ );
793
+ } else if (effect.type === "BACKGROUND_BLUR") {
794
+ const radius = px(effect.radius ?? 0) ?? "0px";
795
+ backdropFilter = backdropFilter
796
+ ? `${backdropFilter} blur(${radius})`
797
+ : `blur(${radius})`;
798
+ tracker.record(
799
+ node,
800
+ "approximated",
801
+ "BACKGROUND_BLUR mapped 1:1 to CSS backdrop-filter: blur(); same radius-scale caveat as LAYER_BLUR.",
802
+ );
803
+ }
804
+ }
805
+
806
+ return { boxShadowLayers, filter, backdropFilter };
807
+ }
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // Blend modes
811
+ // ---------------------------------------------------------------------------
812
+
813
+ const CSS_BLEND_MODES = new Set([
814
+ "multiply",
815
+ "screen",
816
+ "overlay",
817
+ "darken",
818
+ "lighten",
819
+ "color-dodge",
820
+ "color-burn",
821
+ "hard-light",
822
+ "soft-light",
823
+ "difference",
824
+ "exclusion",
825
+ "hue",
826
+ "saturation",
827
+ "color",
828
+ "luminosity",
829
+ ]);
830
+
831
+ const FIGMA_ONLY_BLEND_MODE_FALLBACK: Record<string, string> = {
832
+ LINEAR_BURN: "multiply",
833
+ LINEAR_DODGE: "plus-lighter",
834
+ LIGHTER: "plus-lighter",
835
+ DARKER: "darken",
836
+ };
837
+
838
+ function buildBlendMode(
839
+ node: FigmaNode,
840
+ tracker: FidelityTracker,
841
+ ): string | undefined {
842
+ const mode = node.blendMode;
843
+ if (!mode || mode === "PASS_THROUGH" || mode === "NORMAL") return undefined;
844
+ const cssMode = mode.toLowerCase().replace(/_/g, "-");
845
+ if (CSS_BLEND_MODES.has(cssMode)) return cssMode;
846
+ const fallback = FIGMA_ONLY_BLEND_MODE_FALLBACK[mode];
847
+ if (fallback) {
848
+ tracker.record(
849
+ node,
850
+ "approximated",
851
+ `Figma blend mode "${mode}" has no CSS equivalent; approximated as mix-blend-mode: ${fallback}.`,
852
+ );
853
+ return fallback;
854
+ }
855
+ return undefined;
856
+ }
857
+
858
+ // ---------------------------------------------------------------------------
859
+ // Text styling
860
+ // ---------------------------------------------------------------------------
861
+
862
+ function resolveLineHeight(style: FigmaTypeStyle): string | undefined {
863
+ if (
864
+ typeof style.lineHeightPx === "number" &&
865
+ style.lineHeightUnit !== "FONT_SIZE_%"
866
+ ) {
867
+ return px(style.lineHeightPx);
868
+ }
869
+ if (
870
+ typeof style.lineHeightPercentFontSize === "number" &&
871
+ typeof style.fontSize === "number"
872
+ ) {
873
+ // lineHeightPercentFontSize is literally "percent of the font's nominal
874
+ // size" -- resolve to an exact px value rather than a unitless ratio so
875
+ // the rendered line box matches Figma regardless of font metrics.
876
+ return px(style.fontSize * (style.lineHeightPercentFontSize / 100));
877
+ }
878
+ if (typeof style.lineHeightPx === "number") {
879
+ return px(style.lineHeightPx);
880
+ }
881
+ return undefined;
882
+ }
883
+
884
+ function textTransformCss(
885
+ textCase: FigmaTypeStyle["textCase"],
886
+ ): string | undefined {
887
+ switch (textCase) {
888
+ case "UPPER":
889
+ return "uppercase";
890
+ case "LOWER":
891
+ return "lowercase";
892
+ case "TITLE":
893
+ return "capitalize";
894
+ default:
895
+ return undefined;
896
+ }
897
+ }
898
+
899
+ function textDecorationCss(
900
+ decoration: FigmaTypeStyle["textDecoration"],
901
+ ): string | undefined {
902
+ switch (decoration) {
903
+ case "UNDERLINE":
904
+ return "underline";
905
+ case "STRIKETHROUGH":
906
+ return "line-through";
907
+ default:
908
+ return undefined;
909
+ }
910
+ }
911
+
912
+ function textAlignCss(
913
+ align: FigmaTypeStyle["textAlignHorizontal"],
914
+ ): string | undefined {
915
+ switch (align) {
916
+ case "CENTER":
917
+ return "center";
918
+ case "RIGHT":
919
+ return "right";
920
+ case "JUSTIFIED":
921
+ return "justify";
922
+ case "LEFT":
923
+ return "left";
924
+ default:
925
+ return undefined;
926
+ }
927
+ }
928
+
929
+ function verticalAlignJustifyContent(
930
+ align: FigmaTypeStyle["textAlignVertical"],
931
+ ): string {
932
+ switch (align) {
933
+ case "CENTER":
934
+ return "center";
935
+ case "BOTTOM":
936
+ return "flex-end";
937
+ default:
938
+ return "flex-start";
939
+ }
940
+ }
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // Auto-layout
944
+ // ---------------------------------------------------------------------------
945
+
946
+ function primaryAxisJustify(align: FigmaNode["primaryAxisAlignItems"]): string {
947
+ switch (align) {
948
+ case "CENTER":
949
+ return "center";
950
+ case "MAX":
951
+ return "flex-end";
952
+ case "SPACE_BETWEEN":
953
+ return "space-between";
954
+ default:
955
+ return "flex-start";
956
+ }
957
+ }
958
+
959
+ function counterAxisAlign(align: FigmaNode["counterAxisAlignItems"]): string {
960
+ switch (align) {
961
+ case "CENTER":
962
+ return "center";
963
+ case "MAX":
964
+ return "flex-end";
965
+ case "BASELINE":
966
+ return "baseline";
967
+ default:
968
+ return "flex-start";
969
+ }
970
+ }
971
+
972
+ function buildAutoLayoutStyles(
973
+ node: FigmaNode,
974
+ ): Record<string, string | undefined> {
975
+ if (
976
+ !node.layoutMode ||
977
+ node.layoutMode === "NONE" ||
978
+ node.layoutMode === "GRID"
979
+ ) {
980
+ return {};
981
+ }
982
+ const isHorizontal = node.layoutMode === "HORIZONTAL";
983
+ const styles: Record<string, string | undefined> = {
984
+ display: "flex",
985
+ "flex-direction": isHorizontal ? "row" : "column",
986
+ "justify-content": primaryAxisJustify(node.primaryAxisAlignItems),
987
+ "align-items": counterAxisAlign(node.counterAxisAlignItems),
988
+ };
989
+ if (node.layoutWrap === "WRAP") styles["flex-wrap"] = "wrap";
990
+ if (typeof node.itemSpacing === "number" && node.itemSpacing !== 0) {
991
+ styles[isHorizontal ? "column-gap" : "row-gap"] = px(node.itemSpacing);
992
+ }
993
+ if (
994
+ typeof node.counterAxisSpacing === "number" &&
995
+ node.counterAxisSpacing !== 0
996
+ ) {
997
+ styles[isHorizontal ? "row-gap" : "column-gap"] = px(
998
+ node.counterAxisSpacing,
999
+ );
1000
+ }
1001
+ const padTop = node.paddingTop ?? 0;
1002
+ const padRight = node.paddingRight ?? 0;
1003
+ const padBottom = node.paddingBottom ?? 0;
1004
+ const padLeft = node.paddingLeft ?? 0;
1005
+ if (padTop || padRight || padBottom || padLeft) {
1006
+ styles.padding = `${px(padTop)} ${px(padRight)} ${px(padBottom)} ${px(padLeft)}`;
1007
+ }
1008
+ return styles;
1009
+ }
1010
+
1011
+ /**
1012
+ * "FILL" sizing must map to different CSS depending on which axis is the
1013
+ * flex *main* axis for this node's parent: main-axis FILL grows via
1014
+ * `flex-grow`/`flex-basis` (row parent -> horizontal FILL, column parent ->
1015
+ * vertical FILL); cross-axis FILL stretches via `align-self: stretch` (row
1016
+ * parent -> vertical FILL, column parent -> horizontal FILL). Passing only a
1017
+ * `parentHasAutoLayout` boolean (as this used to) loses the row/column
1018
+ * direction and always mapped horizontal-FILL to flex-grow — correct for row
1019
+ * parents but wrong for column parents, where a FILL-width text/rect child
1020
+ * got `width: auto` with no stretch and overflowed to its content width.
1021
+ */
1022
+ function buildChildSizingStyles(
1023
+ node: FigmaNode,
1024
+ parentLayoutMode: "NONE" | "HORIZONTAL" | "VERTICAL",
1025
+ ): Record<string, string | undefined> {
1026
+ if (parentLayoutMode === "NONE") return {};
1027
+ const parentIsHorizontal = parentLayoutMode === "HORIZONTAL";
1028
+ const styles: Record<string, string | undefined> = {};
1029
+ if (node.layoutSizingHorizontal === "FILL") {
1030
+ if (parentIsHorizontal) {
1031
+ styles["flex-grow"] = "1";
1032
+ styles["flex-basis"] = "0%";
1033
+ } else {
1034
+ styles["align-self"] = "stretch";
1035
+ }
1036
+ styles.width = "auto";
1037
+ } else if (node.layoutSizingHorizontal === "HUG") {
1038
+ styles.width = "auto";
1039
+ }
1040
+ if (node.layoutSizingVertical === "FILL") {
1041
+ if (parentIsHorizontal) {
1042
+ styles["align-self"] = "stretch";
1043
+ } else {
1044
+ styles["flex-grow"] = "1";
1045
+ styles["flex-basis"] = "0%";
1046
+ }
1047
+ styles.height = "auto";
1048
+ } else if (node.layoutSizingVertical === "HUG") {
1049
+ styles.height = "auto";
1050
+ }
1051
+ return styles;
1052
+ }
1053
+
1054
+ // ---------------------------------------------------------------------------
1055
+ // Node type classification
1056
+ // ---------------------------------------------------------------------------
1057
+
1058
+ function needsImageFallback(
1059
+ node: FigmaNode,
1060
+ options: MapFigmaNodeOptions,
1061
+ ): boolean {
1062
+ if (options.forceImageFallbackNodeIds?.has(node.id)) return true;
1063
+ if (UNSUPPORTED_STRUCTURAL_TYPES.has(node.type)) return true;
1064
+ if (
1065
+ !SUPPORTED_CONTAINER_TYPES.has(node.type) &&
1066
+ node.type !== "RECTANGLE" &&
1067
+ node.type !== "ELLIPSE" &&
1068
+ node.type !== "LINE" &&
1069
+ node.type !== "TEXT"
1070
+ ) {
1071
+ return true;
1072
+ }
1073
+ return false;
1074
+ }
1075
+
1076
+ /**
1077
+ * Walk a node tree and return the ids of every subtree that will render as a
1078
+ * PNG image fallback (vector networks, boolean ops, and any node type this
1079
+ * mapper does not model structurally). Call this before fetching node data
1080
+ * so the caller can request rendered images for exactly these ids via
1081
+ * `GET /v1/images/:fileKey?ids=...&scale=2`.
1082
+ */
1083
+ export function collectFallbackNodeIds(
1084
+ node: FigmaNode,
1085
+ options: MapFigmaNodeOptions = {},
1086
+ ): string[] {
1087
+ const ids: string[] = [];
1088
+ const visit = (current: FigmaNode) => {
1089
+ if (current.visible === false) return;
1090
+ if (needsImageFallback(current, options)) {
1091
+ ids.push(current.id);
1092
+ return; // Don't recurse into a subtree that's rendered as one image.
1093
+ }
1094
+ for (const child of current.children ?? []) visit(child);
1095
+ };
1096
+ visit(node);
1097
+ return ids;
1098
+ }
1099
+
1100
+ /**
1101
+ * Walk a node tree and return every distinct `imageRef` used by IMAGE fills
1102
+ * (on fills or strokes) so the caller can resolve them to URLs via
1103
+ * `GET /v1/files/:fileKey/images` before mapping.
1104
+ */
1105
+ export function collectImageFillRefs(node: FigmaNode): string[] {
1106
+ const refs = new Set<string>();
1107
+ const visitPaints = (paints: FigmaPaint[] | undefined) => {
1108
+ for (const paint of paints ?? []) {
1109
+ if (paint.type === "IMAGE" && paint.imageRef) refs.add(paint.imageRef);
1110
+ }
1111
+ };
1112
+ const visit = (current: FigmaNode) => {
1113
+ visitPaints(current.fills);
1114
+ visitPaints(current.strokes);
1115
+ for (const child of current.children ?? []) visit(child);
1116
+ };
1117
+ visit(node);
1118
+ return [...refs];
1119
+ }
1120
+
1121
+ // ---------------------------------------------------------------------------
1122
+ // Main mapper
1123
+ // ---------------------------------------------------------------------------
1124
+
1125
+ function frameRelativeBox(
1126
+ node: FigmaNode,
1127
+ parentBox: FigmaBoundingBox | null,
1128
+ ): { left: number; top: number; width: number; height: number } {
1129
+ const box = node.absoluteBoundingBox;
1130
+ if (!box) return { left: 0, top: 0, width: 0, height: 0 };
1131
+ return {
1132
+ left: box.x - (parentBox?.x ?? box.x),
1133
+ top: box.y - (parentBox?.y ?? box.y),
1134
+ width: box.width,
1135
+ height: box.height,
1136
+ };
1137
+ }
1138
+
1139
+ function buildNode(
1140
+ node: FigmaNode,
1141
+ parentBox: FigmaBoundingBox | null,
1142
+ parentLayoutMode: "NONE" | "HORIZONTAL" | "VERTICAL",
1143
+ options: MapFigmaNodeOptions,
1144
+ tracker: FidelityTracker,
1145
+ isRoot: boolean,
1146
+ ): string {
1147
+ const parentHasAutoLayout = parentLayoutMode !== "NONE";
1148
+ if (node.visible === false) return "";
1149
+
1150
+ const box = frameRelativeBox(node, parentBox);
1151
+ const nameAttr = node.name
1152
+ ? ` data-agent-native-layer-name="${escapeAttr(node.name)}"`
1153
+ : "";
1154
+ const idAttr = ` data-figma-node-id="${escapeAttr(node.id)}"`;
1155
+ const typeAttr = ` data-figma-node-type="${escapeAttr(node.type)}"`;
1156
+
1157
+ if (needsImageFallback(node, options)) {
1158
+ const imageUrl = options.fallbackImageUrls?.[node.id];
1159
+ if (!imageUrl) {
1160
+ tracker.record(
1161
+ node,
1162
+ "image-fallback",
1163
+ `Node type "${node.type}" requires an image fallback but no rendered URL was provided; nothing was rendered for this node.`,
1164
+ );
1165
+ return "";
1166
+ }
1167
+ tracker.record(
1168
+ node,
1169
+ "image-fallback",
1170
+ `Node type "${node.type}" cannot be reproduced structurally (vector network / boolean op / unsupported type); rendered as an exact PNG (scale=2) instead of an approximated structural guess.`,
1171
+ );
1172
+ const styles: Record<string, string | undefined> = {
1173
+ position: isRoot ? "relative" : "absolute",
1174
+ left: isRoot ? undefined : px(box.left),
1175
+ top: isRoot ? undefined : px(box.top),
1176
+ width: px(box.width),
1177
+ height: px(box.height),
1178
+ opacity:
1179
+ typeof node.opacity === "number" && node.opacity !== 1
1180
+ ? String(round(node.opacity, 4))
1181
+ : undefined,
1182
+ };
1183
+ return `<img${idAttr}${typeAttr}${nameAttr} src="${escapeAttr(imageUrl)}" alt="${escapeAttr(node.name ?? "")}" style="${styleAttr(styles)}" />`;
1184
+ }
1185
+
1186
+ const isTextNode = node.type === "TEXT";
1187
+ const isEllipse = node.type === "ELLIPSE";
1188
+ const box2 = { width: box.width, height: box.height };
1189
+
1190
+ const fills = buildFills(
1191
+ node,
1192
+ node.fills,
1193
+ box2,
1194
+ options,
1195
+ tracker,
1196
+ isTextNode,
1197
+ );
1198
+ const strokeResult = buildStrokes(node, tracker);
1199
+ const effects = buildEffects(node, isTextNode, tracker);
1200
+ const cornerRadius = isEllipse ? "50%" : buildCornerRadius(node);
1201
+ const blendMode = buildBlendMode(node, tracker);
1202
+
1203
+ const boxShadowParts = [...effects.boxShadowLayers];
1204
+ if (strokeResult.insetShadow) boxShadowParts.push(strokeResult.insetShadow);
1205
+
1206
+ const rotation =
1207
+ typeof node.rotation === "number" && Math.abs(node.rotation) > 0.001
1208
+ ? node.rotation
1209
+ : undefined;
1210
+ if (rotation !== undefined) {
1211
+ tracker.record(
1212
+ node,
1213
+ "approximated",
1214
+ `Rotation (${round(rotation, 2)}deg) reconstructed by pivoting the unrotated box about the absoluteBoundingBox center; exact only when Figma's internal pivot is also the shape's center.`,
1215
+ );
1216
+ }
1217
+
1218
+ const autoLayoutStyles = buildAutoLayoutStyles(node);
1219
+ const childSizingStyles = buildChildSizingStyles(node, parentLayoutMode);
1220
+ const hasAutoLayout = Boolean(autoLayoutStyles.display);
1221
+ // A node is positioned relative to its parent's free canvas (absolute,
1222
+ // left/top from absoluteBoundingBox) unless its *parent* is an auto-layout
1223
+ // container, in which case it's a normal flex item (relative, no left/top)
1224
+ // -- this mirrors Figma's own rule that auto-layout children give up
1225
+ // manual x/y in favor of flex flow.
1226
+ const isFlexChild = !isRoot && parentHasAutoLayout;
1227
+
1228
+ const baseStyles: Record<string, string | undefined> = {
1229
+ position: isRoot ? "relative" : isFlexChild ? "relative" : "absolute",
1230
+ left: isRoot || isFlexChild ? undefined : px(box.left),
1231
+ top: isRoot || isFlexChild ? undefined : px(box.top),
1232
+ width: childSizingStyles.width ?? px(box.width),
1233
+ height: childSizingStyles.height ?? px(box.height),
1234
+ "background-color": fills.backgroundColor,
1235
+ "background-image": fills.backgroundImage,
1236
+ "background-size": fills.backgroundSize,
1237
+ "background-position": fills.backgroundPosition,
1238
+ "background-repeat": fills.backgroundRepeat,
1239
+ color: fills.color,
1240
+ "border-radius": cornerRadius,
1241
+ "box-shadow":
1242
+ boxShadowParts.length > 0 ? boxShadowParts.join(", ") : undefined,
1243
+ filter: effects.filter,
1244
+ "backdrop-filter": effects.backdropFilter,
1245
+ "-webkit-backdrop-filter": effects.backdropFilter,
1246
+ opacity:
1247
+ typeof node.opacity === "number" && node.opacity !== 1
1248
+ ? String(round(node.opacity, 4))
1249
+ : undefined,
1250
+ "mix-blend-mode": blendMode,
1251
+ overflow: node.clipsContent ? "hidden" : undefined,
1252
+ transform:
1253
+ rotation !== undefined ? `rotate(${round(-rotation, 3)}deg)` : undefined,
1254
+ "transform-origin": rotation !== undefined ? "center" : undefined,
1255
+ ...autoLayoutStyles,
1256
+ ...strokeResult.styles,
1257
+ ...childSizingStyles,
1258
+ };
1259
+
1260
+ if (isTextNode) {
1261
+ const style = node.style ?? {};
1262
+ baseStyles["font-family"] = style.fontFamily
1263
+ ? `"${style.fontFamily.replace(/"/g, "")}", sans-serif`
1264
+ : undefined;
1265
+ baseStyles["font-size"] = px(style.fontSize);
1266
+ baseStyles["font-weight"] =
1267
+ typeof style.fontWeight === "number"
1268
+ ? String(style.fontWeight)
1269
+ : undefined;
1270
+ baseStyles["font-style"] = style.italic ? "italic" : undefined;
1271
+ baseStyles["line-height"] = resolveLineHeight(style);
1272
+ baseStyles["letter-spacing"] =
1273
+ typeof style.letterSpacing === "number" && style.letterSpacing !== 0
1274
+ ? px(style.letterSpacing)
1275
+ : undefined;
1276
+ baseStyles["text-transform"] = textTransformCss(style.textCase);
1277
+ baseStyles["text-decoration"] = textDecorationCss(style.textDecoration);
1278
+ baseStyles["text-align"] = textAlignCss(style.textAlignHorizontal);
1279
+ if (style.textAutoResize === "TRUNCATE") {
1280
+ baseStyles["white-space"] = "nowrap";
1281
+ baseStyles.overflow = "hidden";
1282
+ baseStyles["text-overflow"] = "ellipsis";
1283
+ }
1284
+ baseStyles.display = "flex";
1285
+ baseStyles["flex-direction"] = "column";
1286
+ baseStyles["justify-content"] = verticalAlignJustifyContent(
1287
+ style.textAlignVertical,
1288
+ );
1289
+ tracker.record(node, "exact", "Text styling mapped from TypeStyle fields.");
1290
+
1291
+ const characters = node.characters ?? "";
1292
+ return `<div${idAttr}${typeAttr}${nameAttr} style="${styleAttr(baseStyles)}">${escapeHtml(characters)}</div>`;
1293
+ }
1294
+
1295
+ tracker.record(
1296
+ node,
1297
+ "exact",
1298
+ "Position, size, fills, strokes, and effects mapped 1:1.",
1299
+ );
1300
+
1301
+ // hasAutoLayout guarantees node.layoutMode is "HORIZONTAL" or "VERTICAL"
1302
+ // (buildAutoLayoutStyles returns {} -- no `display` -- for "NONE"/"GRID").
1303
+ const childParentLayoutMode: "NONE" | "HORIZONTAL" | "VERTICAL" =
1304
+ hasAutoLayout ? (node.layoutMode as "HORIZONTAL" | "VERTICAL") : "NONE";
1305
+ const childrenHtml = (node.children ?? [])
1306
+ .map((child) =>
1307
+ buildNode(
1308
+ child,
1309
+ node.absoluteBoundingBox ?? null,
1310
+ childParentLayoutMode,
1311
+ options,
1312
+ tracker,
1313
+ false,
1314
+ ),
1315
+ )
1316
+ .filter(Boolean)
1317
+ .join("\n");
1318
+
1319
+ return `<div${idAttr}${typeAttr}${nameAttr} style="${styleAttr(baseStyles)}">\n${childrenHtml}\n</div>`;
1320
+ }
1321
+
1322
+ /**
1323
+ * Map a Figma node (and its subtree) to an HTML fragment plus a fidelity
1324
+ * report describing which properties were exact, approximated, or rendered
1325
+ * as an image fallback.
1326
+ */
1327
+ export function mapFigmaNodeToHtml(
1328
+ node: FigmaNode,
1329
+ options: MapFigmaNodeOptions = {},
1330
+ ): MapFigmaNodeResult {
1331
+ const tracker = new FidelityTracker();
1332
+ const html = buildNode(node, null, "NONE", options, tracker, true);
1333
+ return { html, fidelity: tracker.build() };
1334
+ }