@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,1412 @@
1
+ /**
2
+ * design-to-figma-svg.ts — serialize the design element model (absolutely
3
+ * positioned boxes + flex auto-layout containers, text, images, solid/
4
+ * gradient/image fills, borders, border-radius, shadows/blurs, opacity,
5
+ * rotation) into a standalone, GENUINELY VECTOR SVG document that Figma
6
+ * imports as editable shapes — not the `foreignObject` wrapper produced by
7
+ * `buildSvgForeignObject` in `design-export.ts` (that one round-trips the
8
+ * live DOM/CSS for the editor's own "Download SVG" command, but Figma cannot
9
+ * import `foreignObject` content as vectors — it stays an opaque embedded
10
+ * HTML blob). This module builds real `<rect>`/`<path>`/`<text>`/`<image>`
11
+ * markup with `<linearGradient>`/`<radialGradient>`/`<filter>` defs, which
12
+ * Figma's SVG importer parses into normal editable layers.
13
+ *
14
+ * Two layers:
15
+ *
16
+ * 1. A pure, browser-free SCENE -> SVG serializer (`buildFigmaSvgDocument`
17
+ * and its helpers below). It consumes a `FigmaSvgNode` tree — plain
18
+ * data, no DOM — and emits SVG markup plus an export report. This is
19
+ * the part covered by `design-to-figma-svg.spec.ts` with hand-built
20
+ * fixture nodes (gradient stops, rounded-rect path commands, stroke
21
+ * inset geometry, tspan positions).
22
+ *
23
+ * 2. A Playwright-based SCENE EXTRACTOR (`renderDesignToFigmaSvg`, backed by
24
+ * the in-page `collectRawFigmaSvgScene` walk) that renders the design's
25
+ * real stored HTML in headless Chromium (mirrors
26
+ * `take-design-screenshot.ts`'s launch/import pattern) and walks the
27
+ * live, laid-out DOM to build a `FigmaSvgNode` tree from real
28
+ * `getBoundingClientRect()` / `getComputedStyle()` values. Delegating
29
+ * geometry to the actual browser layout engine is what makes the boxes
30
+ * PIXEL-PERFECT without reimplementing flexbox/auto-layout math by hand
31
+ * — the same reason `take-design-screenshot.ts` renders instead of
32
+ * statically analyzing HTML. This half is exercised in practice via the
33
+ * `export-design-as-figma-svg` action, not vitest (same split as
34
+ * `collectPageDiagnostics` in `take-design-screenshot.ts` — see that
35
+ * file's spec docblock for the rationale).
36
+ */
37
+
38
+ import { parseCssColorExtended } from "../../shared/color-utils.js";
39
+ import { importPlaywright, launchChromium } from "./playwright-runtime.js";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Scene types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface FigmaSvgRect {
46
+ x: number;
47
+ y: number;
48
+ width: number;
49
+ height: number;
50
+ }
51
+
52
+ export interface FigmaSvgCornerRadii {
53
+ tl: number;
54
+ tr: number;
55
+ br: number;
56
+ bl: number;
57
+ }
58
+
59
+ export const ZERO_RADII: FigmaSvgCornerRadii = { tl: 0, tr: 0, br: 0, bl: 0 };
60
+
61
+ export interface FigmaSvgColorStop {
62
+ /** 0-1 */
63
+ offset: number;
64
+ /** Any valid SVG color (rgb()/rgba()/#hex/named). */
65
+ color: string;
66
+ }
67
+
68
+ export type FigmaSvgFillLayer =
69
+ | { kind: "solid"; color: string }
70
+ | { kind: "linear-gradient"; angleDeg: number; stops: FigmaSvgColorStop[] }
71
+ | {
72
+ kind: "radial-gradient";
73
+ stops: FigmaSvgColorStop[];
74
+ /** objectBoundingBox 0-1, default centered circle. */
75
+ cx?: number;
76
+ cy?: number;
77
+ r?: number;
78
+ }
79
+ | {
80
+ kind: "image";
81
+ href: string;
82
+ fit: "cover" | "contain" | "stretch";
83
+ };
84
+
85
+ export interface FigmaSvgShadow {
86
+ offsetX: number;
87
+ offsetY: number;
88
+ blur: number;
89
+ spread: number;
90
+ color: string;
91
+ inset?: boolean;
92
+ }
93
+
94
+ export interface FigmaSvgBorder {
95
+ widthPx: number;
96
+ color: string;
97
+ dashed?: boolean;
98
+ /** Set when the source had non-uniform per-side width/color/style and we
99
+ * fell back to one representative side — surfaced as "approximated". */
100
+ nonUniform?: boolean;
101
+ }
102
+
103
+ export interface FigmaSvgTextLine {
104
+ text: string;
105
+ x: number;
106
+ /** Vertical CENTER of the line box (rendered with dominant-baseline="central"). */
107
+ y: number;
108
+ }
109
+
110
+ export interface FigmaSvgTextStyle {
111
+ fontFamily: string;
112
+ fontSizePx: number;
113
+ fontWeight?: number;
114
+ italic?: boolean;
115
+ letterSpacingPx?: number;
116
+ color: string;
117
+ textAlign?: "left" | "center" | "right" | "justify";
118
+ }
119
+
120
+ export interface FigmaSvgNode {
121
+ /** Stable id used for SVG element ids / gradient-def ids / the export report. */
122
+ id: string;
123
+ /** Human label (from data-agent-native-layer-name or a fallback). */
124
+ name?: string;
125
+ kind: "box" | "text" | "image" | "raster";
126
+ rect: FigmaSvgRect;
127
+ rotationDeg?: number;
128
+ /** 0-1; omit or 1 for fully opaque. */
129
+ opacity?: number;
130
+ cornerRadii?: FigmaSvgCornerRadii;
131
+ /** CSS order: index 0 is the TOPMOST paint layer (painted last in SVG). */
132
+ fills?: FigmaSvgFillLayer[];
133
+ border?: FigmaSvgBorder;
134
+ /** Non-inset drop shadows; inset shadows are reported as approximated/omitted. */
135
+ shadows?: FigmaSvgShadow[];
136
+ text?: { lines: FigmaSvgTextLine[]; style: FigmaSvgTextStyle };
137
+ image?: { href: string; fit: "cover" | "contain" | "stretch" };
138
+ /** Fully rasterized fallback (video/canvas/iframe/backdrop-blur/other unsupported paint). */
139
+ raster?: { href: string; reason: string };
140
+ children?: FigmaSvgNode[];
141
+ }
142
+
143
+ export interface FigmaSvgExportReport {
144
+ vectorized: string[];
145
+ approximated: Array<{ node: string; note: string }>;
146
+ rasterized: Array<{ node: string; reason: string }>;
147
+ omitted: Array<{ node: string; reason: string }>;
148
+ warnings: string[];
149
+ /** One-time caveat surfaced regardless of whether any text node was found. */
150
+ vectorizedTextCaveat: string;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Small formatting helpers
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /** Round to 3 decimal places and strip a trailing ".000"/trailing zeros. */
158
+ export function n(value: number): string {
159
+ if (!Number.isFinite(value)) return "0";
160
+ const rounded = Math.round(value * 1000) / 1000;
161
+ return String(rounded);
162
+ }
163
+
164
+ export function escapeXmlAttr(value: string): string {
165
+ return String(value)
166
+ .replace(/&/g, "&amp;")
167
+ .replace(/"/g, "&quot;")
168
+ .replace(/</g, "&lt;")
169
+ .replace(/>/g, "&gt;");
170
+ }
171
+
172
+ export function escapeXmlText(value: string): string {
173
+ return String(value)
174
+ .replace(/&/g, "&amp;")
175
+ .replace(/</g, "&lt;")
176
+ .replace(/>/g, "&gt;");
177
+ }
178
+
179
+ export function isUniformRadius(radii: FigmaSvgCornerRadii): boolean {
180
+ return (
181
+ radii.tl === radii.tr && radii.tr === radii.br && radii.br === radii.bl
182
+ );
183
+ }
184
+
185
+ export function isZeroRadii(radii: FigmaSvgCornerRadii): boolean {
186
+ return radii.tl === 0 && radii.tr === 0 && radii.br === 0 && radii.bl === 0;
187
+ }
188
+
189
+ export function clampRadius(radius: number, maxRadius: number): number {
190
+ return Math.max(0, Math.min(radius, maxRadius));
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Rounded-rect path (per-corner radii) — SVG's `rx`/`ry` on <rect> is
195
+ // uniform-only, so any element with differing corner radii must be emitted
196
+ // as an explicit path built from four line/arc segments.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export function roundedRectPath(
200
+ rect: FigmaSvgRect,
201
+ radii: FigmaSvgCornerRadii,
202
+ ): string {
203
+ const { x, y, width, height } = rect;
204
+ const maxR = Math.max(0, Math.min(width, height) / 2);
205
+ const tl = clampRadius(radii.tl, maxR);
206
+ const tr = clampRadius(radii.tr, maxR);
207
+ const br = clampRadius(radii.br, maxR);
208
+ const bl = clampRadius(radii.bl, maxR);
209
+ const x2 = x + width;
210
+ const y2 = y + height;
211
+
212
+ return [
213
+ `M ${n(x + tl)} ${n(y)}`,
214
+ `L ${n(x2 - tr)} ${n(y)}`,
215
+ tr > 0 ? `A ${n(tr)} ${n(tr)} 0 0 1 ${n(x2)} ${n(y + tr)}` : "",
216
+ `L ${n(x2)} ${n(y2 - br)}`,
217
+ br > 0 ? `A ${n(br)} ${n(br)} 0 0 1 ${n(x2 - br)} ${n(y2)}` : "",
218
+ `L ${n(x + bl)} ${n(y2)}`,
219
+ bl > 0 ? `A ${n(bl)} ${n(bl)} 0 0 1 ${n(x)} ${n(y2 - bl)}` : "",
220
+ `L ${n(x)} ${n(y + tl)}`,
221
+ tl > 0 ? `A ${n(tl)} ${n(tl)} 0 0 1 ${n(x + tl)} ${n(y)}` : "",
222
+ "Z",
223
+ ]
224
+ .filter(Boolean)
225
+ .join(" ");
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Border stroke geometry — CSS `border` paints INSIDE the box edge (the box's
230
+ // own width/height already include the border band). SVG strokes are
231
+ // centered on the path by default. Insetting the stroke's path by half the
232
+ // stroke width on every side makes the stroke's outer edge land exactly on
233
+ // the box's true edge and its inner edge land exactly border-width inside —
234
+ // i.e. pixel-identical to the CSS border band — while a separate full-rect
235
+ // fill shape (background-clip: border-box) still paints all the way to the
236
+ // true edge underneath it.
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export function insetRectForStroke(
240
+ rect: FigmaSvgRect,
241
+ strokeWidth: number,
242
+ ): FigmaSvgRect {
243
+ const inset = strokeWidth / 2;
244
+ const width = Math.max(0, rect.width - strokeWidth);
245
+ const height = Math.max(0, rect.height - strokeWidth);
246
+ return { x: rect.x + inset, y: rect.y + inset, width, height };
247
+ }
248
+
249
+ export function insetRadiiForStroke(
250
+ radii: FigmaSvgCornerRadii,
251
+ strokeWidth: number,
252
+ ): FigmaSvgCornerRadii {
253
+ const d = strokeWidth / 2;
254
+ const clamp = (r: number) => Math.max(0, r - d);
255
+ return {
256
+ tl: clamp(radii.tl),
257
+ tr: clamp(radii.tr),
258
+ br: clamp(radii.br),
259
+ bl: clamp(radii.bl),
260
+ };
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Gradients
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * CSS `0deg` points "to top"; SVG's default objectBoundingBox gradient
269
+ * vector runs (0,0) -> (1,0), i.e. "to right", which is CSS's `90deg`.
270
+ * Rotating that default vector by `(angleDeg - 90)` around the box center
271
+ * reproduces the CSS direction exactly for a SQUARE element. For a
272
+ * non-square element, objectBoundingBox first non-uniformly scales the unit
273
+ * gradient vector to the box's aspect ratio before the rotation is applied,
274
+ * which skews the visually-apparent angle — documented in the export report
275
+ * as an approximation for non-square boxes, not a bug in this formula.
276
+ */
277
+ export function gradientAngleToRotation(angleDeg: number): number {
278
+ return (((angleDeg - 90) % 360) + 360) % 360;
279
+ }
280
+
281
+ function stopMarkup(stops: FigmaSvgColorStop[]): string {
282
+ return stops
283
+ .map(
284
+ (s) =>
285
+ `<stop offset="${n(s.offset * 100)}%" stop-color="${escapeXmlAttr(s.color)}"/>`,
286
+ )
287
+ .join("");
288
+ }
289
+
290
+ export function buildLinearGradientDef(
291
+ id: string,
292
+ angleDeg: number,
293
+ stops: FigmaSvgColorStop[],
294
+ ): string {
295
+ const rotation = gradientAngleToRotation(angleDeg);
296
+ return `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0" gradientTransform="rotate(${n(rotation)} 0.5 0.5)">${stopMarkup(stops)}</linearGradient>`;
297
+ }
298
+
299
+ export function buildRadialGradientDef(
300
+ id: string,
301
+ stops: FigmaSvgColorStop[],
302
+ opts?: { cx?: number; cy?: number; r?: number },
303
+ ): string {
304
+ const cx = opts?.cx ?? 0.5;
305
+ const cy = opts?.cy ?? 0.5;
306
+ const r = opts?.r ?? 0.5;
307
+ return `<radialGradient id="${id}" cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}">${stopMarkup(stops)}</radialGradient>`;
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Computed-style string parsers — pure, unit-testable without a browser.
312
+ // These assume Chromium's normalized `getComputedStyle` output (the engine
313
+ // `extractFigmaSvgScene` renders with), documented per-function.
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /** Split on top-level commas only — doesn't split inside `rgba(...)`/`rgb(...)` parens. */
317
+ export function splitTopLevelCommas(value: string): string[] {
318
+ const parts: string[] = [];
319
+ let depth = 0;
320
+ let current = "";
321
+ for (const ch of value) {
322
+ if (ch === "(") depth++;
323
+ if (ch === ")") depth--;
324
+ if (ch === "," && depth === 0) {
325
+ parts.push(current.trim());
326
+ current = "";
327
+ } else {
328
+ current += ch;
329
+ }
330
+ }
331
+ if (current.trim()) parts.push(current.trim());
332
+ return parts;
333
+ }
334
+
335
+ const LENGTH_RE = /(-?[\d.]+)px/g;
336
+
337
+ /**
338
+ * Parses Chromium's computed `box-shadow` string, e.g.
339
+ * `"rgba(0, 0, 0, 0.25) 0px 4px 12px 0px"` or `"rgb(0,0,0) 0px 2px 4px 0px inset"`,
340
+ * including multiple comma-separated shadows.
341
+ */
342
+ export function parseComputedBoxShadow(
343
+ value: string | null | undefined,
344
+ ): FigmaSvgShadow[] {
345
+ if (!value || value === "none") return [];
346
+ return splitTopLevelCommas(value).map((part) => {
347
+ const inset = /\binset\b/.test(part);
348
+ const withoutInset = part.replace(/\binset\b/g, "").trim();
349
+ const colorMatch = withoutInset.match(
350
+ /^(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)/,
351
+ );
352
+ const color = colorMatch ? colorMatch[0] : "rgb(0, 0, 0)";
353
+ const rest = colorMatch
354
+ ? withoutInset.slice(colorMatch[0].length)
355
+ : withoutInset;
356
+ const lengths = Array.from(rest.matchAll(LENGTH_RE)).map((m) =>
357
+ Number.parseFloat(m[1]),
358
+ );
359
+ const [offsetX = 0, offsetY = 0, blur = 0, spread = 0] = lengths;
360
+ return { offsetX, offsetY, blur, spread, color, inset };
361
+ });
362
+ }
363
+
364
+ const ANGLE_KEYWORDS: Record<string, number> = {
365
+ "to top": 0,
366
+ "to top right": 45,
367
+ "to right top": 45,
368
+ "to right": 90,
369
+ "to bottom right": 135,
370
+ "to right bottom": 135,
371
+ "to bottom": 180,
372
+ "to bottom left": 225,
373
+ "to left bottom": 225,
374
+ "to left": 270,
375
+ "to top left": 315,
376
+ "to left top": 315,
377
+ };
378
+
379
+ function parseColorStop(part: string): FigmaSvgColorStop {
380
+ const trimmed = part.trim();
381
+ const percentMatch = trimmed.match(/(-?[\d.]+)%\s*$/);
382
+ if (!percentMatch) return { offset: 0, color: trimmed };
383
+ const offset = Number.parseFloat(percentMatch[1]) / 100;
384
+ const color = trimmed.slice(0, percentMatch.index).trim();
385
+ return { offset, color };
386
+ }
387
+
388
+ export interface ParsedGradient {
389
+ angleDeg: number;
390
+ stops: FigmaSvgColorStop[];
391
+ }
392
+
393
+ /**
394
+ * Parses Chromium's computed `linear-gradient(...)` string. Assumes explicit
395
+ * percentage stops, which Chromium always fills in on computed style even
396
+ * when the source omitted them.
397
+ */
398
+ export function parseComputedLinearGradient(
399
+ value: string,
400
+ ): ParsedGradient | null {
401
+ const match = value.match(/linear-gradient\((.*)\)\s*$/s);
402
+ if (!match) return null;
403
+ const parts = splitTopLevelCommas(match[1]);
404
+ let angleDeg = 180; // CSS default direction is "to bottom".
405
+ let stopParts = parts;
406
+ const first = (parts[0] ?? "").trim();
407
+ const degMatch = first.match(/^(-?[\d.]+)deg$/);
408
+ if (degMatch) {
409
+ angleDeg = Number.parseFloat(degMatch[1]);
410
+ stopParts = parts.slice(1);
411
+ } else if (/^to\s/.test(first) && first in ANGLE_KEYWORDS) {
412
+ angleDeg = ANGLE_KEYWORDS[first];
413
+ stopParts = parts.slice(1);
414
+ }
415
+ return { angleDeg, stops: stopParts.map(parseColorStop) };
416
+ }
417
+
418
+ /**
419
+ * Parses Chromium's computed `radial-gradient(...)` string. Shape/position
420
+ * nuances (ellipse vs circle, off-center `at` position) are not modeled —
421
+ * always mapped to a centered circle spanning the element's bounding box —
422
+ * so callers should surface this as an approximation.
423
+ */
424
+ export function parseComputedRadialGradient(
425
+ value: string,
426
+ ): { stops: FigmaSvgColorStop[] } | null {
427
+ const match = value.match(/radial-gradient\((.*)\)\s*$/s);
428
+ if (!match) return null;
429
+ const parts = splitTopLevelCommas(match[1]);
430
+ const stopParts = /\bat\b|circle|ellipse/.test(parts[0] ?? "")
431
+ ? parts.slice(1)
432
+ : parts;
433
+ return { stops: stopParts.map(parseColorStop) };
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // object-fit -> preserveAspectRatio
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * SVG `preserveAspectRatio="... slice"` clips content to the image's own
442
+ * x/y/width/height viewport, which reproduces CSS `object-fit: cover`
443
+ * exactly with no extra `<clipPath>` needed. `object-position` is not
444
+ * modeled beyond the common `center` case (always emits `xMidYMid`) —
445
+ * approximated for any other alignment.
446
+ */
447
+ export function objectFitToPreserveAspectRatio(
448
+ fit: "cover" | "contain" | "stretch" | "none" | "scale-down",
449
+ ): string {
450
+ if (fit === "stretch" || fit === "none") return "none";
451
+ if (fit === "contain" || fit === "scale-down") return "xMidYMid meet";
452
+ return "xMidYMid slice"; // cover (default)
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Shadow filter defs
457
+ // ---------------------------------------------------------------------------
458
+
459
+ function floodColorParts(color: string): {
460
+ floodColor: string;
461
+ floodOpacity: number;
462
+ } {
463
+ const parsed = parseCssColorExtended(color);
464
+ if (!parsed) return { floodColor: color, floodOpacity: 1 };
465
+ return {
466
+ floodColor: `rgb(${Math.round(parsed.r)}, ${Math.round(parsed.g)}, ${Math.round(parsed.b)})`,
467
+ floodOpacity: parsed.a,
468
+ };
469
+ }
470
+
471
+ /**
472
+ * Builds a `<filter>` def for the non-inset shadows in `shadows` (inset
473
+ * shadows are the caller's responsibility to report as approximated/omitted
474
+ * — SVG has no direct inset-shadow primitive). Shadows with `spread === 0`
475
+ * use a plain `feDropShadow` chain; any shadow with a non-zero spread needs
476
+ * the decomposed feMorphology (dilate/erode) + feGaussianBlur + feOffset +
477
+ * feFlood + feComposite chain, since `feDropShadow` has no spread parameter.
478
+ * `stdDeviation = blur / 2` is the standard CSS-blur-radius-to-SVG-Gaussian
479
+ * conversion used across browsers/renderers.
480
+ */
481
+ export function buildShadowFilterDef(
482
+ id: string,
483
+ shadows: FigmaSvgShadow[],
484
+ ): string {
485
+ const outer = shadows.filter((s) => !s.inset);
486
+ if (outer.length === 0) return "";
487
+
488
+ if (outer.every((s) => s.spread === 0)) {
489
+ const drops = outer
490
+ .map((s) => {
491
+ const { floodColor, floodOpacity } = floodColorParts(s.color);
492
+ return `<feDropShadow dx="${n(s.offsetX)}" dy="${n(s.offsetY)}" stdDeviation="${n(s.blur / 2)}" flood-color="${escapeXmlAttr(floodColor)}" flood-opacity="${n(floodOpacity)}"/>`;
493
+ })
494
+ .join("");
495
+ return `<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%">${drops}</filter>`;
496
+ }
497
+
498
+ const parts = outer
499
+ .map((s, i) => {
500
+ const morph =
501
+ s.spread !== 0
502
+ ? `<feMorphology in="SourceAlpha" operator="${s.spread > 0 ? "dilate" : "erode"}" radius="${n(Math.abs(s.spread))}" result="spread${i}"/>`
503
+ : "";
504
+ const blurIn = s.spread !== 0 ? `spread${i}` : "SourceAlpha";
505
+ const { floodColor, floodOpacity } = floodColorParts(s.color);
506
+ return (
507
+ `${morph}` +
508
+ `<feGaussianBlur in="${blurIn}" stdDeviation="${n(s.blur / 2)}" result="blur${i}"/>` +
509
+ `<feOffset in="blur${i}" dx="${n(s.offsetX)}" dy="${n(s.offsetY)}" result="offset${i}"/>` +
510
+ `<feFlood flood-color="${escapeXmlAttr(floodColor)}" flood-opacity="${n(floodOpacity)}" result="color${i}"/>` +
511
+ `<feComposite in="color${i}" in2="offset${i}" operator="in" result="shadow${i}"/>`
512
+ );
513
+ })
514
+ .join("");
515
+ const merge = `<feMerge>${outer.map((_, i) => `<feMergeNode in="shadow${i}"/>`).join("")}<feMergeNode in="SourceGraphic"/></feMerge>`;
516
+ return `<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%">${parts}${merge}</filter>`;
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Node renderer
521
+ // ---------------------------------------------------------------------------
522
+
523
+ interface RenderCtx {
524
+ defs: string[];
525
+ report: FigmaSvgExportReport;
526
+ nextId: (prefix: string) => string;
527
+ }
528
+
529
+ function wrapGroup(
530
+ markup: string,
531
+ node: Pick<FigmaSvgNode, "rect" | "rotationDeg" | "opacity">,
532
+ ): string {
533
+ const attrs: string[] = [];
534
+ if (node.rotationDeg) {
535
+ const cx = node.rect.x + node.rect.width / 2;
536
+ const cy = node.rect.y + node.rect.height / 2;
537
+ attrs.push(`transform="rotate(${n(node.rotationDeg)} ${n(cx)} ${n(cy)})"`);
538
+ }
539
+ if (node.opacity !== undefined && node.opacity !== 1) {
540
+ attrs.push(`opacity="${n(node.opacity)}"`);
541
+ }
542
+ if (attrs.length === 0) return markup;
543
+ return `<g ${attrs.join(" ")}>${markup}</g>`;
544
+ }
545
+
546
+ function resolveFillPaint(
547
+ fill: FigmaSvgFillLayer,
548
+ node: FigmaSvgNode,
549
+ ctx: RenderCtx,
550
+ ): string {
551
+ if (fill.kind === "solid") return fill.color;
552
+
553
+ if (fill.kind === "linear-gradient") {
554
+ const id = ctx.nextId("lg");
555
+ ctx.defs.push(buildLinearGradientDef(id, fill.angleDeg, fill.stops));
556
+ const isSquareish = Math.abs(node.rect.width - node.rect.height) < 1;
557
+ if (!isSquareish) {
558
+ ctx.report.approximated.push({
559
+ node: node.name || node.id,
560
+ note: "Linear gradient angle mapped via objectBoundingBox rotation; exact for square elements, approximated for this element's non-square aspect ratio.",
561
+ });
562
+ }
563
+ return `url(#${id})`;
564
+ }
565
+
566
+ if (fill.kind === "radial-gradient") {
567
+ const id = ctx.nextId("rg");
568
+ ctx.defs.push(
569
+ buildRadialGradientDef(id, fill.stops, {
570
+ cx: fill.cx,
571
+ cy: fill.cy,
572
+ r: fill.r,
573
+ }),
574
+ );
575
+ ctx.report.approximated.push({
576
+ node: node.name || node.id,
577
+ note: "Radial gradient shape/position approximated as a centered circle over the element's bounding box.",
578
+ });
579
+ return `url(#${id})`;
580
+ }
581
+
582
+ // image fill
583
+ const id = ctx.nextId("img-fill");
584
+ const par = objectFitToPreserveAspectRatio(fill.fit);
585
+ ctx.defs.push(
586
+ `<pattern id="${id}" patternUnits="objectBoundingBox" width="1" height="1"><image href="${escapeXmlAttr(fill.href)}" x="0" y="0" width="${n(node.rect.width)}" height="${n(node.rect.height)}" preserveAspectRatio="${par}"/></pattern>`,
587
+ );
588
+ ctx.report.approximated.push({
589
+ node: node.name || node.id,
590
+ note: "Background-image fill approximated via an objectBoundingBox pattern; exact cover/contain cropping may differ from the browser for extreme aspect ratios.",
591
+ });
592
+ return `url(#${id})`;
593
+ }
594
+
595
+ function renderBox(node: FigmaSvgNode, ctx: RenderCtx): string {
596
+ const rect = node.rect;
597
+ const radii = node.cornerRadii ?? ZERO_RADII;
598
+ const fills = node.fills ?? [];
599
+
600
+ let filterId: string | null = null;
601
+ if (node.shadows && node.shadows.length > 0) {
602
+ const insetShadows = node.shadows.filter((s) => s.inset);
603
+ if (insetShadows.length > 0) {
604
+ ctx.report.approximated.push({
605
+ node: node.name || node.id,
606
+ note: `${insetShadows.length} inset shadow(s) omitted — SVG has no direct inset-shadow primitive.`,
607
+ });
608
+ }
609
+ const id = ctx.nextId("shadow");
610
+ const def = buildShadowFilterDef(id, node.shadows);
611
+ if (def) {
612
+ ctx.defs.push(def);
613
+ filterId = id;
614
+ }
615
+ }
616
+
617
+ const fillTag = (
618
+ r: FigmaSvgRect,
619
+ radiiForShape: FigmaSvgCornerRadii,
620
+ paint: string,
621
+ filterAttr: string,
622
+ ) =>
623
+ isUniformRadius(radiiForShape)
624
+ ? `<rect x="${n(r.x)}" y="${n(r.y)}" width="${n(r.width)}" height="${n(r.height)}"${radiiForShape.tl ? ` rx="${n(radiiForShape.tl)}"` : ""} fill="${paint}"${filterAttr}/>`
625
+ : `<path d="${roundedRectPath(r, radiiForShape)}" fill="${paint}"${filterAttr}/>`;
626
+
627
+ // CSS background layer 0 is the TOPMOST paint; SVG paints later elements
628
+ // on top, so emit layers in reverse (last CSS layer first). Only the
629
+ // topmost (last-emitted) shape carries the shadow filter — lower layers
630
+ // must not double-apply it.
631
+ const layers =
632
+ fills.length > 0
633
+ ? fills
634
+ : [{ kind: "solid", color: "none" } as FigmaSvgFillLayer];
635
+ const reversedLayers = layers.slice().reverse();
636
+ let body = reversedLayers
637
+ .map((f, i) => {
638
+ const isTopmost = i === reversedLayers.length - 1;
639
+ const paint = resolveFillPaint(f, node, ctx);
640
+ const filterAttr =
641
+ isTopmost && filterId ? ` filter="url(#${filterId})"` : "";
642
+ return fillTag(rect, radii, paint, filterAttr);
643
+ })
644
+ .join("");
645
+
646
+ if (node.border && node.border.widthPx > 0) {
647
+ const insetRect = insetRectForStroke(rect, node.border.widthPx);
648
+ const insetRadii = insetRadiiForStroke(radii, node.border.widthPx);
649
+ const dash = node.border.dashed
650
+ ? ` stroke-dasharray="${n(node.border.widthPx * 2)} ${n(node.border.widthPx)}"`
651
+ : "";
652
+ body += isUniformRadius(insetRadii)
653
+ ? `<rect x="${n(insetRect.x)}" y="${n(insetRect.y)}" width="${n(insetRect.width)}" height="${n(insetRect.height)}"${insetRadii.tl ? ` rx="${n(insetRadii.tl)}"` : ""} fill="none" stroke="${escapeXmlAttr(node.border.color)}" stroke-width="${n(node.border.widthPx)}"${dash}/>`
654
+ : `<path d="${roundedRectPath(insetRect, insetRadii)}" fill="none" stroke="${escapeXmlAttr(node.border.color)}" stroke-width="${n(node.border.widthPx)}"${dash}/>`;
655
+ if (node.border.nonUniform) {
656
+ ctx.report.approximated.push({
657
+ node: node.name || node.id,
658
+ note: "Border had differing per-side width/color/style; rendered using one representative side.",
659
+ });
660
+ }
661
+ }
662
+
663
+ ctx.report.vectorized.push(node.name || node.id);
664
+ const childrenMarkup = (node.children ?? [])
665
+ .map((child) => renderFigmaSvgNode(child, ctx))
666
+ .join("");
667
+ return wrapGroup(body + childrenMarkup, node);
668
+ }
669
+
670
+ function renderText(node: FigmaSvgNode, ctx: RenderCtx): string {
671
+ if (!node.text) return "";
672
+ const { style, lines } = node.text;
673
+ const anchor =
674
+ style.textAlign === "center"
675
+ ? "middle"
676
+ : style.textAlign === "right"
677
+ ? "end"
678
+ : "start";
679
+ if (style.textAlign === "justify") {
680
+ ctx.report.approximated.push({
681
+ node: node.name || node.id,
682
+ note: "text-align: justify has no SVG equivalent; rendered left-aligned.",
683
+ });
684
+ }
685
+
686
+ const tspans = lines
687
+ .map(
688
+ (l) =>
689
+ `<tspan x="${n(l.x)}" y="${n(l.y)}">${escapeXmlText(l.text)}</tspan>`,
690
+ )
691
+ .join("");
692
+ const attrs = [
693
+ `font-family="${escapeXmlAttr(style.fontFamily)}"`,
694
+ `font-size="${n(style.fontSizePx)}"`,
695
+ style.fontWeight ? `font-weight="${style.fontWeight}"` : "",
696
+ style.italic ? `font-style="italic"` : "",
697
+ style.letterSpacingPx ? `letter-spacing="${n(style.letterSpacingPx)}"` : "",
698
+ `fill="${escapeXmlAttr(style.color)}"`,
699
+ `text-anchor="${anchor}"`,
700
+ `dominant-baseline="central"`,
701
+ ]
702
+ .filter(Boolean)
703
+ .join(" ");
704
+
705
+ ctx.report.vectorized.push(node.name || node.id);
706
+ return wrapGroup(`<text ${attrs}>${tspans}</text>`, node);
707
+ }
708
+
709
+ function renderImage(node: FigmaSvgNode, ctx: RenderCtx): string {
710
+ if (!node.image) return "";
711
+ const rect = node.rect;
712
+ const radii = node.cornerRadii ?? ZERO_RADII;
713
+ const par = objectFitToPreserveAspectRatio(node.image.fit);
714
+ let clipAttr = "";
715
+ if (!isZeroRadii(radii)) {
716
+ const clipId = ctx.nextId("clip");
717
+ const shape = isUniformRadius(radii)
718
+ ? `<rect x="${n(rect.x)}" y="${n(rect.y)}" width="${n(rect.width)}" height="${n(rect.height)}" rx="${n(radii.tl)}"/>`
719
+ : `<path d="${roundedRectPath(rect, radii)}"/>`;
720
+ ctx.defs.push(`<clipPath id="${clipId}">${shape}</clipPath>`);
721
+ clipAttr = ` clip-path="url(#${clipId})"`;
722
+ }
723
+ ctx.report.vectorized.push(node.name || node.id);
724
+ const markup = `<image x="${n(rect.x)}" y="${n(rect.y)}" width="${n(rect.width)}" height="${n(rect.height)}" href="${escapeXmlAttr(node.image.href)}" preserveAspectRatio="${par}"${clipAttr}/>`;
725
+ return wrapGroup(markup, node);
726
+ }
727
+
728
+ function renderRaster(node: FigmaSvgNode, ctx: RenderCtx): string {
729
+ if (!node.raster) return "";
730
+ ctx.report.rasterized.push({
731
+ node: node.name || node.id,
732
+ reason: node.raster.reason,
733
+ });
734
+ const rect = node.rect;
735
+ const markup = `<image x="${n(rect.x)}" y="${n(rect.y)}" width="${n(rect.width)}" height="${n(rect.height)}" href="${escapeXmlAttr(node.raster.href)}" preserveAspectRatio="none"/>`;
736
+ return wrapGroup(markup, node);
737
+ }
738
+
739
+ export function renderFigmaSvgNode(node: FigmaSvgNode, ctx: RenderCtx): string {
740
+ switch (node.kind) {
741
+ case "box":
742
+ return renderBox(node, ctx);
743
+ case "text":
744
+ return renderText(node, ctx);
745
+ case "image":
746
+ return renderImage(node, ctx);
747
+ case "raster":
748
+ return renderRaster(node, ctx);
749
+ default:
750
+ return "";
751
+ }
752
+ }
753
+
754
+ export function createEmptyFigmaSvgReport(): FigmaSvgExportReport {
755
+ return {
756
+ vectorized: [],
757
+ approximated: [],
758
+ rasterized: [],
759
+ omitted: [],
760
+ warnings: [],
761
+ vectorizedTextCaveat:
762
+ "SVG <text> elements have pixel-exact geometry, but Figma converts ALL " +
763
+ "imported SVG text to outlined vector paths on paste/drag-import — it " +
764
+ "will not be live, editable type in Figma. This is a Figma import " +
765
+ "limitation, not a defect in this export.",
766
+ };
767
+ }
768
+
769
+ export function buildFigmaSvgDocument(args: {
770
+ width: number;
771
+ height: number;
772
+ title?: string | null;
773
+ root: FigmaSvgNode;
774
+ }): { svg: string; report: FigmaSvgExportReport } {
775
+ const report = createEmptyFigmaSvgReport();
776
+ let idCounter = 0;
777
+ const ctx: RenderCtx = {
778
+ defs: [],
779
+ report,
780
+ nextId: (prefix) => `${prefix}-${++idCounter}`,
781
+ };
782
+ const body = renderFigmaSvgNode(args.root, ctx);
783
+ const defsBlock = ctx.defs.length ? `<defs>${ctx.defs.join("")}</defs>` : "";
784
+ const titleTag = args.title
785
+ ? `<title>${escapeXmlText(args.title)}</title>`
786
+ : "";
787
+ const svg =
788
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
789
+ `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${n(args.width)}" height="${n(args.height)}" viewBox="0 0 ${n(args.width)} ${n(args.height)}">` +
790
+ `${titleTag}${defsBlock}${body}</svg>`;
791
+ return { svg, report };
792
+ }
793
+
794
+ export function safeFigmaSvgFilename(title: string | null | undefined): string {
795
+ const safe = (title || "design")
796
+ .replace(/[^a-zA-Z0-9_-]/g, "-")
797
+ .replace(/-+/g, "-")
798
+ .replace(/^-|-$/g, "");
799
+ return `${safe || "design"}-figma-${Date.now()}.svg`;
800
+ }
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // Raw scene (browser-extracted) -> FigmaSvgNode hydration
804
+ // ---------------------------------------------------------------------------
805
+ //
806
+ // `extractFigmaSvgScene` below walks the LIVE rendered DOM inside Playwright
807
+ // and returns a tree of `RawFigmaSvgNode` — mostly-untouched computed-style
808
+ // STRINGS plus real geometry from getBoundingClientRect(). The functions in
809
+ // this section turn that raw tree into the final `FigmaSvgNode` tree consumed
810
+ // by `buildFigmaSvgDocument` above, reusing the same pure
811
+ // `parseComputedBoxShadow` / `parseComputedLinearGradient` /
812
+ // `parseComputedRadialGradient` parsers already covered by
813
+ // `design-to-figma-svg.spec.ts` — so this hydration step is itself pure and
814
+ // unit-testable with a hand-built `RawFigmaSvgNode` fixture (no browser
815
+ // needed), even though the DOM WALK that produces the raw tree is not.
816
+
817
+ export interface RawFigmaSvgTextLine {
818
+ text: string;
819
+ x: number;
820
+ y: number;
821
+ }
822
+
823
+ export interface RawFigmaSvgTextStyle {
824
+ fontFamily: string;
825
+ fontSizePx: number;
826
+ fontWeight: number;
827
+ italic: boolean;
828
+ letterSpacingPx: number;
829
+ color: string;
830
+ textAlign: string;
831
+ }
832
+
833
+ export interface RawFigmaSvgNode {
834
+ id: string;
835
+ name?: string;
836
+ domTag: string;
837
+ rect: FigmaSvgRect;
838
+ rotationDeg: number;
839
+ opacity: number;
840
+ cornerRadiiRaw: FigmaSvgCornerRadii;
841
+ /** Computed `background-color`, e.g. "rgba(0, 0, 0, 0)" or "rgb(255, 255, 255)". */
842
+ backgroundColor: string;
843
+ /** Computed `background-image`, e.g. "none" or a comma-separated gradient/url list. */
844
+ backgroundImage: string;
845
+ /** Computed `box-shadow`, e.g. "none" or a Chromium-normalized shadow list. */
846
+ boxShadow: string;
847
+ borderWidthPx: number;
848
+ borderColor: string;
849
+ borderStyle: string;
850
+ borderNonUniform: boolean;
851
+ backdropFilter: string;
852
+ isLeafText: boolean;
853
+ textLines?: RawFigmaSvgTextLine[];
854
+ textStyle?: RawFigmaSvgTextStyle;
855
+ imgSrc?: string;
856
+ imgObjectFit?: string;
857
+ /** Set when this node must be rasterized (video/canvas/iframe/backdrop-blur/other unsupported paint). */
858
+ rasterReason?: string;
859
+ /** Filled in by the orchestrator after a screenshot crop (data: URI or hosted URL). */
860
+ rasterHref?: string;
861
+ children: RawFigmaSvgNode[];
862
+ }
863
+
864
+ /**
865
+ * Normalizes CSS `object-fit` to the 3-way union `FigmaSvgFill`/`image` fit
866
+ * accepts. `none` (no scaling) and `scale-down` (contain, but never upscale)
867
+ * both approximate to `contain` — closest available SVG mapping.
868
+ */
869
+ function objectFitFromRaw(raw?: string): "cover" | "contain" | "stretch" {
870
+ if (raw === "cover") return "cover";
871
+ if (raw === "contain" || raw === "none" || raw === "scale-down")
872
+ return "contain";
873
+ return "stretch"; // CSS default object-fit is "fill", closest SVG mapping is "stretch".
874
+ }
875
+
876
+ /**
877
+ * Builds the ordered `FigmaSvgFillLayer[]` for a box's own background paint:
878
+ * each comma-separated `background-image` layer (gradients/url()) in CSS
879
+ * order (index 0 = topmost), followed by `background-color` as the implicit
880
+ * bottommost layer when it isn't fully transparent.
881
+ */
882
+ export function buildFillLayersFromComputedStyle(
883
+ backgroundColor: string,
884
+ backgroundImage: string,
885
+ ): FigmaSvgFillLayer[] {
886
+ const layers: FigmaSvgFillLayer[] = [];
887
+
888
+ if (backgroundImage && backgroundImage !== "none") {
889
+ for (const part of splitTopLevelCommas(backgroundImage)) {
890
+ if (part.startsWith("linear-gradient")) {
891
+ const parsed = parseComputedLinearGradient(part);
892
+ if (parsed) {
893
+ layers.push({
894
+ kind: "linear-gradient",
895
+ angleDeg: parsed.angleDeg,
896
+ stops: parsed.stops,
897
+ });
898
+ }
899
+ } else if (part.startsWith("radial-gradient")) {
900
+ const parsed = parseComputedRadialGradient(part);
901
+ if (parsed)
902
+ layers.push({ kind: "radial-gradient", stops: parsed.stops });
903
+ } else if (part.startsWith("url(")) {
904
+ const hrefMatch = part.match(/url\((["']?)(.*?)\1\)/);
905
+ if (hrefMatch)
906
+ layers.push({ kind: "image", href: hrefMatch[2], fit: "cover" });
907
+ }
908
+ }
909
+ }
910
+
911
+ const bg = parseCssColorExtended(backgroundColor);
912
+ if (bg && bg.a > 0) {
913
+ layers.push({
914
+ kind: "solid",
915
+ color: `rgba(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)}, ${bg.a})`,
916
+ });
917
+ }
918
+
919
+ return layers;
920
+ }
921
+
922
+ /** Pure hydration: `RawFigmaSvgNode` (browser-extracted computed strings + geometry) -> `FigmaSvgNode`. */
923
+ export function hydrateRawFigmaSvgNode(raw: RawFigmaSvgNode): FigmaSvgNode {
924
+ const rotationDeg = raw.rotationDeg ? raw.rotationDeg : undefined;
925
+ const opacity = raw.opacity !== 1 ? raw.opacity : undefined;
926
+
927
+ if (raw.rasterReason) {
928
+ return {
929
+ id: raw.id,
930
+ name: raw.name,
931
+ kind: "raster",
932
+ rect: raw.rect,
933
+ rotationDeg,
934
+ opacity,
935
+ raster: { href: raw.rasterHref ?? "", reason: raw.rasterReason },
936
+ };
937
+ }
938
+
939
+ if (raw.isLeafText && raw.textLines && raw.textStyle) {
940
+ const textAlign =
941
+ raw.textStyle.textAlign === "center" ||
942
+ raw.textStyle.textAlign === "right" ||
943
+ raw.textStyle.textAlign === "justify"
944
+ ? raw.textStyle.textAlign
945
+ : "left";
946
+ return {
947
+ id: raw.id,
948
+ name: raw.name,
949
+ kind: "text",
950
+ rect: raw.rect,
951
+ rotationDeg,
952
+ opacity,
953
+ text: {
954
+ lines: raw.textLines,
955
+ style: {
956
+ fontFamily: raw.textStyle.fontFamily,
957
+ fontSizePx: raw.textStyle.fontSizePx,
958
+ fontWeight: raw.textStyle.fontWeight,
959
+ italic: raw.textStyle.italic,
960
+ letterSpacingPx: raw.textStyle.letterSpacingPx,
961
+ color: raw.textStyle.color,
962
+ textAlign,
963
+ },
964
+ },
965
+ };
966
+ }
967
+
968
+ if (raw.domTag === "IMG" && raw.imgSrc) {
969
+ return {
970
+ id: raw.id,
971
+ name: raw.name,
972
+ kind: "image",
973
+ rect: raw.rect,
974
+ rotationDeg,
975
+ opacity,
976
+ cornerRadii: isZeroRadii(raw.cornerRadiiRaw)
977
+ ? undefined
978
+ : raw.cornerRadiiRaw,
979
+ image: { href: raw.imgSrc, fit: objectFitFromRaw(raw.imgObjectFit) },
980
+ };
981
+ }
982
+
983
+ const fills = buildFillLayersFromComputedStyle(
984
+ raw.backgroundColor,
985
+ raw.backgroundImage,
986
+ );
987
+ const shadows = parseComputedBoxShadow(raw.boxShadow);
988
+ const border =
989
+ raw.borderWidthPx > 0
990
+ ? {
991
+ widthPx: raw.borderWidthPx,
992
+ color: raw.borderColor,
993
+ dashed: raw.borderStyle === "dashed" || raw.borderStyle === "dotted",
994
+ nonUniform: raw.borderNonUniform || undefined,
995
+ }
996
+ : undefined;
997
+
998
+ return {
999
+ id: raw.id,
1000
+ name: raw.name,
1001
+ kind: "box",
1002
+ rect: raw.rect,
1003
+ rotationDeg,
1004
+ opacity,
1005
+ cornerRadii: isZeroRadii(raw.cornerRadiiRaw)
1006
+ ? undefined
1007
+ : raw.cornerRadiiRaw,
1008
+ fills: fills.length > 0 ? fills : undefined,
1009
+ border,
1010
+ shadows: shadows.length > 0 ? shadows : undefined,
1011
+ children: raw.children.map(hydrateRawFigmaSvgNode),
1012
+ };
1013
+ }
1014
+
1015
+ // ---------------------------------------------------------------------------
1016
+ // In-page DOM walk — mirrors take-design-screenshot.ts's
1017
+ // `collectPageDiagnostics`: a single self-contained function with no closures
1018
+ // over outer scope (Playwright serializes it via `Function#toString()` into
1019
+ // the page), so it duplicates a few tiny helpers rather than importing them.
1020
+ // Not unit-tested directly for the same reason `collectPageDiagnostics` isn't
1021
+ // — see that file's spec docblock. Geometry comes straight from
1022
+ // `getBoundingClientRect()`, which is what makes it pixel-perfect without
1023
+ // reimplementing flexbox/auto-layout math.
1024
+ // ---------------------------------------------------------------------------
1025
+
1026
+ interface RawFigmaSvgSceneResult {
1027
+ root: RawFigmaSvgNode;
1028
+ /** The export root's absolute page-space offset, so the orchestrator can
1029
+ * convert a raster node's origin-relative rect back to page coordinates
1030
+ * for `page.screenshot({ clip })`. */
1031
+ originOffset: { x: number; y: number };
1032
+ }
1033
+
1034
+ function collectRawFigmaSvgScene(
1035
+ rootSelector: string | null,
1036
+ ): RawFigmaSvgSceneResult | null {
1037
+ const root = rootSelector
1038
+ ? document.querySelector(rootSelector)
1039
+ : document.body;
1040
+ if (!root) return null;
1041
+ const originRect = root.getBoundingClientRect();
1042
+ let autoId = 0;
1043
+
1044
+ function nextId(): string {
1045
+ autoId += 1;
1046
+ return `n${autoId}`;
1047
+ }
1048
+
1049
+ function rotationFromTransform(transform: string): number {
1050
+ if (!transform || transform === "none") return 0;
1051
+ const m = transform.match(/matrix\(([^)]+)\)/);
1052
+ if (!m) return 0;
1053
+ const parts = m[1].split(",").map((v) => Number.parseFloat(v.trim()));
1054
+ const [a, b] = parts;
1055
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
1056
+ return Math.atan2(b, a) * (180 / Math.PI);
1057
+ }
1058
+
1059
+ function isVisible(el: Element, style: CSSStyleDeclaration): boolean {
1060
+ if (style.display === "none" || style.visibility === "hidden") return false;
1061
+ if (el.getAttribute("data-agent-native-hidden") === "true") return false;
1062
+ const rect = el.getBoundingClientRect();
1063
+ return rect.width > 0 && rect.height > 0;
1064
+ }
1065
+
1066
+ function splitLineOffsets(
1067
+ node: Text,
1068
+ totalLength: number,
1069
+ lineCount: number,
1070
+ ): number[] {
1071
+ const offsets: number[] = [];
1072
+ let start = 0;
1073
+ const range = document.createRange();
1074
+ for (let line = 0; line < lineCount - 1; line++) {
1075
+ let lo = start;
1076
+ let hi = totalLength;
1077
+ while (lo < hi) {
1078
+ const mid = Math.ceil((lo + hi) / 2);
1079
+ range.setStart(node, start);
1080
+ range.setEnd(node, mid);
1081
+ const rects = Array.from(range.getClientRects());
1082
+ const tops = new Set(rects.map((r) => Math.round(r.top)));
1083
+ if (tops.size <= 1) {
1084
+ lo = mid;
1085
+ } else {
1086
+ hi = mid - 1;
1087
+ }
1088
+ }
1089
+ offsets.push(lo || start + 1);
1090
+ start = offsets[offsets.length - 1];
1091
+ }
1092
+ offsets.push(totalLength);
1093
+ return offsets;
1094
+ }
1095
+
1096
+ function extractTextLines(el: Element): RawFigmaSvgTextLine[] | null {
1097
+ const textNode = Array.from(el.childNodes).find(
1098
+ (c) =>
1099
+ c.nodeType === Node.TEXT_NODE &&
1100
+ (c.textContent || "").trim().length > 0,
1101
+ ) as Text | undefined;
1102
+ if (!textNode || el.children.length > 0) return null;
1103
+ const full = textNode.textContent || "";
1104
+ const range = document.createRange();
1105
+ range.selectNodeContents(textNode);
1106
+ const lineRects = Array.from(range.getClientRects());
1107
+ if (lineRects.length === 0) return null;
1108
+
1109
+ const style = getComputedStyle(el);
1110
+ const textAlign = style.textAlign;
1111
+ const anchorX = (rect: DOMRect) => {
1112
+ if (textAlign === "center") return rect.left + rect.width / 2;
1113
+ if (textAlign === "right" || textAlign === "end") return rect.right;
1114
+ return rect.left;
1115
+ };
1116
+
1117
+ if (lineRects.length === 1) {
1118
+ const r = lineRects[0];
1119
+ return [
1120
+ {
1121
+ text: full.trim(),
1122
+ x: anchorX(r) - originRect.left,
1123
+ y: r.top + r.height / 2 - originRect.top,
1124
+ },
1125
+ ];
1126
+ }
1127
+
1128
+ const offsets = splitLineOffsets(textNode, full.length, lineRects.length);
1129
+ let start = 0;
1130
+ return lineRects.map((r, i) => {
1131
+ const end = offsets[i] ?? full.length;
1132
+ const text = full.slice(start, end).trim();
1133
+ start = end;
1134
+ return {
1135
+ text,
1136
+ x: anchorX(r) - originRect.left,
1137
+ y: r.top + r.height / 2 - originRect.top,
1138
+ };
1139
+ });
1140
+ }
1141
+
1142
+ function walk(el: Element): RawFigmaSvgNode | null {
1143
+ const style = getComputedStyle(el);
1144
+ if (!isVisible(el, style)) return null;
1145
+
1146
+ const rect = el.getBoundingClientRect();
1147
+ const relRect: FigmaSvgRect = {
1148
+ x: rect.left - originRect.left,
1149
+ y: rect.top - originRect.top,
1150
+ width: rect.width,
1151
+ height: rect.height,
1152
+ };
1153
+ const name = el.getAttribute("data-agent-native-layer-name") || undefined;
1154
+ const id = el.getAttribute("data-agent-native-node-id") || nextId();
1155
+ const tag = el.tagName.toUpperCase();
1156
+
1157
+ const widths = [
1158
+ Number.parseFloat(style.borderTopWidth) || 0,
1159
+ Number.parseFloat(style.borderRightWidth) || 0,
1160
+ Number.parseFloat(style.borderBottomWidth) || 0,
1161
+ Number.parseFloat(style.borderLeftWidth) || 0,
1162
+ ];
1163
+ const colors = [
1164
+ style.borderTopColor,
1165
+ style.borderRightColor,
1166
+ style.borderBottomColor,
1167
+ style.borderLeftColor,
1168
+ ];
1169
+ const styles = [
1170
+ style.borderTopStyle,
1171
+ style.borderRightStyle,
1172
+ style.borderBottomStyle,
1173
+ style.borderLeftStyle,
1174
+ ];
1175
+ const borderNonUniform =
1176
+ widths.some((w) => Math.abs(w - widths[0]) > 0.5) ||
1177
+ colors.some((c) => c !== colors[0]) ||
1178
+ styles.some((s) => s !== styles[0]);
1179
+
1180
+ const base = {
1181
+ id,
1182
+ name,
1183
+ domTag: tag,
1184
+ rect: relRect,
1185
+ rotationDeg: rotationFromTransform(style.transform),
1186
+ opacity: Number.parseFloat(style.opacity || "1"),
1187
+ cornerRadiiRaw: {
1188
+ tl: Number.parseFloat(style.borderTopLeftRadius) || 0,
1189
+ tr: Number.parseFloat(style.borderTopRightRadius) || 0,
1190
+ br: Number.parseFloat(style.borderBottomRightRadius) || 0,
1191
+ bl: Number.parseFloat(style.borderBottomLeftRadius) || 0,
1192
+ },
1193
+ backgroundColor: style.backgroundColor,
1194
+ backgroundImage: style.backgroundImage,
1195
+ boxShadow: style.boxShadow,
1196
+ borderWidthPx: styles[0] === "none" ? 0 : widths[0],
1197
+ borderColor: colors[0],
1198
+ borderStyle: styles[0],
1199
+ borderNonUniform,
1200
+ backdropFilter:
1201
+ (style as CSSStyleDeclaration & { backdropFilter?: string })
1202
+ .backdropFilter || "none",
1203
+ isLeafText: false,
1204
+ children: [] as RawFigmaSvgNode[],
1205
+ };
1206
+
1207
+ if (tag === "VIDEO" || tag === "CANVAS" || tag === "IFRAME") {
1208
+ return {
1209
+ ...base,
1210
+ rasterReason: `<${tag.toLowerCase()}> content has no SVG equivalent — rasterized via screenshot.`,
1211
+ };
1212
+ }
1213
+ if (base.backdropFilter !== "none") {
1214
+ return {
1215
+ ...base,
1216
+ rasterReason:
1217
+ "backdrop-filter cannot be expressed in SVG — rasterized this element's region via screenshot.",
1218
+ };
1219
+ }
1220
+
1221
+ if (tag === "IMG") {
1222
+ const img = el as HTMLImageElement;
1223
+ return {
1224
+ ...base,
1225
+ imgSrc: img.currentSrc || img.src,
1226
+ imgObjectFit: getComputedStyle(img).objectFit,
1227
+ };
1228
+ }
1229
+
1230
+ const lines = extractTextLines(el);
1231
+ if (lines) {
1232
+ return {
1233
+ ...base,
1234
+ isLeafText: true,
1235
+ textLines: lines,
1236
+ textStyle: {
1237
+ fontFamily: style.fontFamily,
1238
+ fontSizePx: Number.parseFloat(style.fontSize) || 16,
1239
+ fontWeight: Number.parseInt(style.fontWeight, 10) || 400,
1240
+ italic: style.fontStyle === "italic",
1241
+ letterSpacingPx:
1242
+ style.letterSpacing === "normal"
1243
+ ? 0
1244
+ : Number.parseFloat(style.letterSpacing) || 0,
1245
+ color: style.color,
1246
+ textAlign: style.textAlign,
1247
+ },
1248
+ };
1249
+ }
1250
+
1251
+ const children: RawFigmaSvgNode[] = [];
1252
+ for (const child of Array.from(el.children)) {
1253
+ const childNode = walk(child);
1254
+ if (childNode) children.push(childNode);
1255
+ }
1256
+ return { ...base, children };
1257
+ }
1258
+
1259
+ const rootNode = walk(root);
1260
+ if (!rootNode) return null;
1261
+ return {
1262
+ root: rootNode,
1263
+ originOffset: { x: originRect.left, y: originRect.top },
1264
+ };
1265
+ }
1266
+
1267
+ // ---------------------------------------------------------------------------
1268
+ // Orchestration — renders the design's HTML in headless Chromium (same
1269
+ // launch/import pattern as take-design-screenshot.ts), walks the live DOM,
1270
+ // hydrates the result into a FigmaSvgNode tree, and serializes it to SVG.
1271
+ // ---------------------------------------------------------------------------
1272
+
1273
+ export interface RenderFigmaSvgOptions {
1274
+ html: string;
1275
+ width: number;
1276
+ height: number;
1277
+ title?: string | null;
1278
+ /** CSS selector to scope a subtree export (e.g. `[data-agent-native-node-id="..."]`). */
1279
+ rootSelector?: string | null;
1280
+ /** Fetch and inline http(s) image `src`/background-image URLs as data: URIs. */
1281
+ embedImages?: boolean;
1282
+ }
1283
+
1284
+ async function embedRemoteImages(node: FigmaSvgNode): Promise<void> {
1285
+ const jobs: Array<Promise<void>> = [];
1286
+
1287
+ function visit(n: FigmaSvgNode) {
1288
+ if (n.kind === "image" && n.image && /^https?:\/\//i.test(n.image.href)) {
1289
+ jobs.push(
1290
+ fetchAsDataUri(n.image.href).then((dataUri) => {
1291
+ if (dataUri && n.image) n.image.href = dataUri;
1292
+ }),
1293
+ );
1294
+ }
1295
+ for (const fill of n.fills ?? []) {
1296
+ if (fill.kind === "image" && /^https?:\/\//i.test(fill.href)) {
1297
+ jobs.push(
1298
+ fetchAsDataUri(fill.href).then((dataUri) => {
1299
+ if (dataUri) fill.href = dataUri;
1300
+ }),
1301
+ );
1302
+ }
1303
+ }
1304
+ for (const child of n.children ?? []) visit(child);
1305
+ }
1306
+ visit(node);
1307
+ await Promise.all(jobs);
1308
+ }
1309
+
1310
+ async function fetchAsDataUri(url: string): Promise<string | null> {
1311
+ try {
1312
+ const res = await fetch(url);
1313
+ if (!res.ok) return null;
1314
+ const contentType = res.headers.get("content-type") || "image/png";
1315
+ const buffer = Buffer.from(await res.arrayBuffer());
1316
+ return `data:${contentType};base64,${buffer.toString("base64")}`;
1317
+ } catch {
1318
+ return null;
1319
+ }
1320
+ }
1321
+
1322
+ /**
1323
+ * Walks the RAW scene tree (before hydration) and, for every node flagged
1324
+ * `rasterReason` (video/canvas/iframe/backdrop-blur), takes a real cropped
1325
+ * screenshot of that element's exact bounds while the page is still live,
1326
+ * setting `rasterHref` to a `data:image/png` URI. Runs while `page` is still
1327
+ * open, since it needs the live rendered content — the same reason
1328
+ * `take-design-screenshot.ts` keeps its browser open for the whole capture.
1329
+ */
1330
+ async function rasterizeUnsupportedNodes(
1331
+ page: import("@playwright/test").Page,
1332
+ node: RawFigmaSvgNode,
1333
+ originOffset: { x: number; y: number },
1334
+ ): Promise<void> {
1335
+ if (node.rasterReason && !node.rasterHref) {
1336
+ const clip = {
1337
+ x: Math.max(0, node.rect.x + originOffset.x),
1338
+ y: Math.max(0, node.rect.y + originOffset.y),
1339
+ width: Math.max(1, Math.round(node.rect.width)),
1340
+ height: Math.max(1, Math.round(node.rect.height)),
1341
+ };
1342
+ try {
1343
+ const png = await page.screenshot({ clip, type: "png" });
1344
+ node.rasterHref = `data:image/png;base64,${png.toString("base64")}`;
1345
+ } catch {
1346
+ // Leave rasterHref unset — hydrateRawFigmaSvgNode falls back to an
1347
+ // empty href, and the export report still names the node as
1348
+ // rasterized (with its reason) so the caller knows what's missing.
1349
+ }
1350
+ }
1351
+ for (const child of node.children) {
1352
+ await rasterizeUnsupportedNodes(page, child, originOffset);
1353
+ }
1354
+ }
1355
+
1356
+ /**
1357
+ * Renders `html` in headless Chromium, walks the live DOM to build a
1358
+ * `FigmaSvgNode` scene, and serializes it into a genuinely vector SVG
1359
+ * document via `buildFigmaSvgDocument`. Throws when no Chromium binary is
1360
+ * available — callers should catch and fall back (mirrors
1361
+ * `take-design-screenshot.ts`'s `chromiumUnavailableReason` pattern).
1362
+ */
1363
+ export async function renderDesignToFigmaSvg(
1364
+ options: RenderFigmaSvgOptions,
1365
+ ): Promise<{ svg: string; report: FigmaSvgExportReport }> {
1366
+ const playwright = await importPlaywright();
1367
+ const browser = await launchChromium(playwright.chromium);
1368
+ try {
1369
+ const context = await browser.newContext({
1370
+ viewport: { width: options.width, height: options.height },
1371
+ });
1372
+ const page = await context.newPage();
1373
+ try {
1374
+ await page.setContent(options.html, { waitUntil: "networkidle" });
1375
+ await page.waitForTimeout(300); // let Alpine.js / CDN Tailwind JIT settle.
1376
+
1377
+ const scene = (await page.evaluate(
1378
+ collectRawFigmaSvgScene,
1379
+ options.rootSelector ?? null,
1380
+ )) as RawFigmaSvgSceneResult | null;
1381
+ if (!scene) {
1382
+ throw new Error(
1383
+ options.rootSelector
1384
+ ? `No element matched rootSelector "${options.rootSelector}"`
1385
+ : "Design screen has no renderable content",
1386
+ );
1387
+ }
1388
+
1389
+ // Capture a real cropped screenshot for every node the DOM walk
1390
+ // flagged as unsupported (video/canvas/iframe/backdrop-blur), while
1391
+ // the page is still live — this is the "rasterize instead of fight
1392
+ // it" fallback the property-mapping matrix promises for those cases.
1393
+ await rasterizeUnsupportedNodes(page, scene.root, scene.originOffset);
1394
+
1395
+ const root = hydrateRawFigmaSvgNode(scene.root);
1396
+ if (options.embedImages) {
1397
+ await embedRemoteImages(root);
1398
+ }
1399
+
1400
+ return buildFigmaSvgDocument({
1401
+ width: options.width,
1402
+ height: options.height,
1403
+ title: options.title,
1404
+ root,
1405
+ });
1406
+ } finally {
1407
+ await context.close().catch(() => {});
1408
+ }
1409
+ } finally {
1410
+ await browser.close().catch(() => {});
1411
+ }
1412
+ }