@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +29 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +214 -17
- package/corpus/core/src/agent/run-manager.ts +72 -14
- package/corpus/core/src/agent/run-store.ts +152 -27
- package/corpus/core/src/cli/design-connect.ts +433 -151
- package/corpus/core/src/cli/skills.ts +14 -0
- package/corpus/core/src/client/AssistantChat.tsx +25 -0
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +40 -6
- package/corpus/core/src/client/agent-chat-adapter.ts +56 -12
- package/corpus/core/src/client/agent-chat.ts +165 -1
- package/corpus/core/src/client/chat/index.ts +9 -1
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +38 -2
- package/corpus/core/src/client/index.ts +5 -0
- package/corpus/core/src/client/rich-markdown-editor/useCollabReconcile.ts +18 -7
- package/corpus/core/src/collab/awareness-store.ts +23 -12
- package/corpus/core/src/collab/client.ts +16 -5
- package/corpus/core/src/collab/index.ts +4 -0
- package/corpus/core/src/collab/recent-edits.ts +65 -2
- package/corpus/core/src/index.browser.ts +1 -0
- package/corpus/core/src/server/agent-chat-plugin.ts +98 -11
- package/corpus/core/src/server/builder-design-systems.ts +30 -4
- package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +1 -1
- package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +12 -6
- package/corpus/templates/clips/AGENTS.md +4 -0
- package/corpus/templates/clips/actions/create-recording.ts +2 -0
- package/corpus/templates/clips/actions/lib/create-recording-schema.ts +5 -3
- package/corpus/templates/clips/actions/request-transcript.ts +102 -33
- package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -0
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +59 -19
- package/corpus/templates/clips/app/components/player/transcript-panel.tsx +75 -9
- package/corpus/templates/clips/app/components/player/video-player.tsx +17 -12
- package/corpus/templates/clips/app/hooks/use-player-shortcuts.ts +1 -0
- package/corpus/templates/clips/app/i18n/ar-SA.ts +1 -0
- package/corpus/templates/clips/app/i18n/de-DE.ts +1 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
- package/corpus/templates/clips/app/i18n/es-ES.ts +1 -0
- package/corpus/templates/clips/app/i18n/fr-FR.ts +1 -0
- package/corpus/templates/clips/app/i18n/hi-IN.ts +1 -0
- package/corpus/templates/clips/app/i18n/ja-JP.ts +1 -0
- package/corpus/templates/clips/app/i18n/ko-KR.ts +1 -0
- package/corpus/templates/clips/app/i18n/pt-BR.ts +1 -0
- package/corpus/templates/clips/app/i18n/zh-CN.ts +1 -0
- package/corpus/templates/clips/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +70 -37
- package/corpus/templates/clips/app/routes/record.tsx +17 -4
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
- package/corpus/templates/clips/changelog/2026-07-09-desktop-popover-sits-closer-to-the-menu-bar.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-desktop-recording-optimization-progress-stays-visible-until-the-clip-opens.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-fixed-large-video-file-uploads-failing-before-their-resumabl.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-fixed-new-video-uploads-defaulting-to-private.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-long-recordings-now-get-enough-time-to-download-their-media-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-meeting-notes-no-longer-lowers-your-microphone-volume-for-ot.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-on-mobile-tapping-a-playing-clip-reveals-playback-controls-w.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-regenerate-any-clip-transcript-from-its-saved-recording-or-r.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-09-slack-previews-now-show-the-clip-length-alongside-its-descri.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-10-fixed-copying-agent-responses-and-transcript-text-from-recor.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +12 -2
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +8 -1
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +8 -5
- package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +5 -5
- package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +3 -0
- package/corpus/templates/clips/desktop/src/styles.css +6 -3
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +29 -11
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1 -1
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +103 -21
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +117 -39
- package/corpus/templates/clips/server/lib/slack-unfurls.ts +20 -1
- package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +10 -4
- package/corpus/templates/clips/shared/recording-core.ts +12 -0
- package/corpus/templates/content/AGENTS.md +7 -6
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +82 -7
- package/corpus/templates/content/actions/_database-source-utils.ts +149 -30
- package/corpus/templates/content/actions/_document-flush.ts +132 -17
- package/corpus/templates/content/actions/_notion-action-utils.ts +15 -0
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +4 -7
- package/corpus/templates/content/actions/attach-content-database-source.ts +41 -17
- package/corpus/templates/content/actions/change-content-database-source-role.ts +46 -8
- package/corpus/templates/content/actions/create-and-link-notion-page.ts +2 -0
- package/corpus/templates/content/actions/link-notion-page.ts +2 -0
- package/corpus/templates/content/actions/pull-document.ts +5 -7
- package/corpus/templates/content/actions/pull-notion-page.ts +2 -0
- package/corpus/templates/content/actions/push-notion-page.ts +10 -0
- package/corpus/templates/content/actions/resolve-notion-sync-conflict.ts +2 -0
- package/corpus/templates/content/actions/suggest-source-join-key.ts +8 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +88 -23
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +32 -2
- package/corpus/templates/content/app/hooks/use-notion.ts +6 -6
- package/corpus/templates/content/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/content/app/i18n-data.ts +18 -0
- package/corpus/templates/content/changelog/2026-07-09-builder-source-refreshes-now-keep-mapped-fields-such-as-topi.md +6 -0
- package/corpus/templates/content/changelog/2026-07-09-notion-conflict-choices-and-version-restores-preserve-docume.md +6 -0
- package/corpus/templates/content/server/lib/notion-sync.ts +80 -64
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +29 -2
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +86 -0
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +57 -0
- package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +11 -0
- package/corpus/templates/design/AGENTS.md +45 -10
- package/corpus/templates/design/actions/add-localhost-screens.ts +66 -13
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +45 -36
- package/corpus/templates/design/actions/apply-motion-edit.ts +25 -31
- package/corpus/templates/design/actions/apply-shader-fill.ts +29 -42
- package/corpus/templates/design/actions/detach-component-instance.ts +337 -0
- package/corpus/templates/design/actions/export-design-as-figma-svg.ts +219 -0
- package/corpus/templates/design/actions/generate-design.ts +79 -12
- package/corpus/templates/design/actions/generate-screens.ts +57 -12
- package/corpus/templates/design/actions/get-figma-styles.ts +115 -0
- package/corpus/templates/design/actions/go-to-main-component.ts +254 -0
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +4 -3
- package/corpus/templates/design/actions/import-design-source.ts +4 -31
- package/corpus/templates/design/actions/import-figma-clipboard.ts +134 -0
- package/corpus/templates/design/actions/import-figma-frame.ts +95 -0
- package/corpus/templates/design/actions/index-design-system-with-builder.ts +16 -1
- package/corpus/templates/design/actions/list-design-components.ts +104 -0
- package/corpus/templates/design/actions/list-figma-library-assets.ts +1 -24
- package/corpus/templates/design/actions/open-component-source.ts +2 -4
- package/corpus/templates/design/actions/present-design-variants.ts +412 -16
- package/corpus/templates/design/actions/preview-source-edit.ts +14 -1
- package/corpus/templates/design/actions/run-design-audit.ts +25 -3
- package/corpus/templates/design/actions/show-design-questions.ts +10 -0
- package/corpus/templates/design/actions/swap-component-instance.ts +544 -0
- package/corpus/templates/design/actions/take-design-screenshot.ts +45 -60
- package/corpus/templates/design/actions/write-local-file.ts +25 -1
- package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +152 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1077 -58
- package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +5 -5
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +73 -21
- package/corpus/templates/design/app/components/design/EditPanel.tsx +16 -11
- package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +364 -0
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +180 -53
- package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +0 -2
- package/corpus/templates/design/app/components/design/MotionDock.tsx +144 -21
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1152 -167
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +27 -5
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +23 -2
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +63 -49
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1293 -54
- package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +7 -0
- package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +6 -1
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +23 -6
- package/corpus/templates/design/app/components/design/bridge/sample.bridge.ts +7 -0
- package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +5 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/MonacoHost.tsx +14 -2
- package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +37 -2
- package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open-guard.ts +25 -0
- package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open.ts +19 -1
- package/corpus/templates/design/app/components/design/code-workbench/model-registry.ts +43 -5
- package/corpus/templates/design/app/components/design/code-workbench/search/SearchView.tsx +27 -2
- package/corpus/templates/design/app/components/design/code-workbench/search/search-engine.ts +10 -1
- package/corpus/templates/design/app/components/design/code-workbench/store.tsx +19 -2
- package/corpus/templates/design/app/components/design/design-canvas/annotation-snapshot.ts +265 -0
- package/corpus/templates/design/app/components/design/design-canvas/annotation-submit.ts +16 -5
- package/corpus/templates/design/app/components/design/design-canvas/coordinate-transforms.ts +16 -2
- package/corpus/templates/design/app/components/design/design-canvas/embedded-frame.ts +7 -2
- package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +3 -1
- package/corpus/templates/design/app/components/design/edit-panel/code-inspect-helpers.tsx +78 -8
- package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +451 -73
- package/corpus/templates/design/app/components/design/edit-panel/document-colors.ts +12 -4
- package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +398 -272
- package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +41 -7
- package/corpus/templates/design/app/components/design/edit-panel/element-identity.ts +16 -3
- package/corpus/templates/design/app/components/design/edit-panel/field-primitives.tsx +24 -4
- package/corpus/templates/design/app/components/design/edit-panel/fill-gradient-helpers.ts +222 -4
- package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +171 -78
- package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +45 -5
- package/corpus/templates/design/app/components/design/edit-panel/layout-properties.tsx +299 -28
- package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +120 -150
- package/corpus/templates/design/app/components/design/edit-panel/position-helpers.ts +53 -1
- package/corpus/templates/design/app/components/design/edit-panel/position-layout-properties.tsx +284 -117
- package/corpus/templates/design/app/components/design/edit-panel/selection-helpers.ts +81 -0
- package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +19 -14
- package/corpus/templates/design/app/components/design/edit-panel/style-options.ts +7 -0
- package/corpus/templates/design/app/components/design/edit-panel/typography-helpers.ts +155 -0
- package/corpus/templates/design/app/components/design/edit-panel/typography-properties.tsx +268 -46
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +350 -31
- package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +13 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +233 -23
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +72 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +51 -11
- package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +84 -11
- package/corpus/templates/design/app/components/design/inspector/index.ts +3 -0
- package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +552 -0
- package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +157 -0
- package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +53 -0
- package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +184 -0
- package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +403 -26
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +20 -1
- package/corpus/templates/design/app/components/design/types.ts +33 -0
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +59 -7
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +119 -17
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +275 -63
- package/corpus/templates/design/app/hooks/use-question-flow.ts +14 -3
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +67 -10
- package/corpus/templates/design/app/i18n/zh-TW.ts +91 -1
- package/corpus/templates/design/app/i18n-data.ts +1217 -10
- package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +577 -0
- package/corpus/templates/design/app/lib/agent-chat.ts +23 -0
- package/corpus/templates/design/app/lib/design-import.ts +24 -0
- package/corpus/templates/design/app/lib/figma-clipboard.ts +157 -0
- package/corpus/templates/design/app/lib/figma-svg-copy.ts +221 -0
- package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +143 -30
- package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +15 -0
- package/corpus/templates/design/app/pages/design-editor/editor-session.ts +33 -0
- package/corpus/templates/design/app/pages/design-editor/editor-state.ts +38 -0
- package/corpus/templates/design/app/pages/design-editor/overview-annotation-context.ts +157 -0
- package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +62 -6
- package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +245 -0
- package/corpus/templates/design/app/pages/design-editor/react-semantic-handoff.ts +586 -0
- package/corpus/templates/design/app/pages/design-editor/screen-auto-layout.ts +169 -0
- package/corpus/templates/design/app/pages/design-editor/selection-state.ts +201 -1
- package/corpus/templates/design/changelog/2026-07-09-added-underline-strikethrough-and-text-case-uppercase-lowerc.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-annotate-now-draws-across-the-all-screens-canvas-without-swi.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-auto-layout-children-can-now-be-dragged-into-freeform-screen.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-board-shapes-now-stay-visible-and-nest-correctly-across-the-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-component-source-navigation-now-opens-reliably-from-the-insp.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-cross-screen-local-react-layer-moves-now-hand-off-exact-sour.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-cross-screen-moves-undo-and-redo-now-keep-both-screens-stabl.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-detach-a-component-instance-into-plain-editable-eleme.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-dragging-layers-into-auto-layout-now-respects-ignore-auto-la.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-drawing-annotations-sent-to-the-agent-now-include-a-rendered.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-editor-sidebars-now-use-figma-matched-default-widths-for-a-r.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-empty-frames-and-rectangles-now-expose-full-auto-layout-cont.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-export-any-design-screen-as-a-real-vector-svg-for-figma-with.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-grid-auto-layout-now-uses-true-responsive-rows-and-columns-w.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-hidden-and-locked-screens-now-behave-consistently-between-la.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-image-fills-now-preserve-valid-urls-and-layered-fit-settings.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-import-figma-frames-as-pixel-accurate-design-screens-via-a-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-inspect-code-now-hides-internal-styling-metadata-and-wraps-l.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-inspector-blur-and-shadow-effects-now-preserve-other-effects.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-jump-to-a-components-other-instances-from-the-inspecto.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-keyboard-shortcuts-are-now-discoverable-in-a-figma-style-bot.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-layer-selection-now-keeps-cmd-or-ctrl-toggles-and-shift-rang.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-layers-dropped-into-auto-layout-now-participate-correctly-in.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-local-live-edit-screens-now-enter-and-leave-full-view-withou.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-local-preview-urls-now-live-in-the-inspector-instead-of-taki.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-local-react-layers-can-now-be-hidden-and-locked-from-layers-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-local-react-layers-now-keep-exact-source-provenance-and-supp.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-local-react-visual-editing-now-loads-reliably-and-shows-live.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-paste-from-figma-cmd-v-now-imports-exact-editable-nodes-when.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-pressing-escape-after-selecting-a-screen-now-reveals-page-pr.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-right-click-overlapping-or-nested-objects-to-choose-the-exac.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-scale-edge-stretch-and-center-constraints-now-preserve-layer.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-screen-rows-can-now-be-reordered-in-layers-with-matching-can.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-screen-selection-stays-responsive-in-designs-with-hundreds-o.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-shift-a-now-enables-auto-layout-on-a-selected-screen-with-fl.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-swap-a-component-instance-for-another-from-the-libra.md +6 -0
- package/corpus/templates/design/changelog/2026-07-10-drawing-annotations-now-stay-available-to-retry-when-the.md +6 -0
- package/corpus/templates/design/e2e/global-setup.ts +3 -6
- package/corpus/templates/design/e2e/helpers.ts +27 -6
- package/corpus/templates/design/server/lib/design-to-figma-svg.ts +1412 -0
- package/corpus/templates/design/server/lib/figma-clipboard-match.ts +196 -0
- package/corpus/templates/design/server/lib/figma-node-import.ts +311 -0
- package/corpus/templates/design/server/lib/figma-node-to-html.ts +1334 -0
- package/corpus/templates/design/server/lib/figma-paste-fallback.ts +64 -0
- package/corpus/templates/design/server/lib/playwright-runtime.ts +78 -0
- package/corpus/templates/design/server/plugins/db.ts +41 -0
- package/corpus/templates/design/server/source-workspace.ts +64 -0
- package/corpus/templates/design/shared/board-file.ts +13 -2
- package/corpus/templates/design/shared/canvas-math.ts +231 -33
- package/corpus/templates/design/shared/code-layer.ts +122 -8
- package/corpus/templates/design/shared/component-library.ts +161 -0
- package/corpus/templates/design/shared/db-conflict.ts +27 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +2 -1
- package/corpus/templates/design/shared/figma-url.ts +149 -0
- package/corpus/templates/design/shared/pen-path.ts +16 -7
- package/corpus/templates/design/shared/source-mode.ts +1 -0
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +7 -19
- package/corpus/templates/plan/changelog/2026-07-09-canvas-panning-now-keeps-zoom-fixed-even-at-the-edges.md +6 -0
- package/corpus/templates/slides/actions/index-design-system-with-builder.ts +16 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +311 -70
- package/corpus/templates/slides/changelog/2026-07-09-fixed-the-slide-rail-going-permanently-stale-during-a-long-a.md +6 -0
- package/dist/agent/production-agent.d.ts +82 -7
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +180 -17
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +63 -14
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +46 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +129 -22
- package/dist/agent/run-store.js.map +1 -1
- package/dist/cli/design-connect.d.ts +6 -0
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +336 -139
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +14 -0
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +2 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +19 -3
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +38 -18
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +48 -12
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/agent-chat.d.ts +60 -0
- package/dist/client/agent-chat.d.ts.map +1 -1
- package/dist/client/agent-chat.js +122 -1
- package/dist/client/agent-chat.js.map +1 -1
- package/dist/client/chat/index.d.ts +1 -1
- package/dist/client/chat/index.d.ts.map +1 -1
- package/dist/client/chat/index.js +1 -1
- package/dist/client/chat/index.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts +12 -0
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +36 -2
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.js +18 -6
- package/dist/client/rich-markdown-editor/useCollabReconcile.js.map +1 -1
- package/dist/collab/awareness-store.d.ts +5 -0
- package/dist/collab/awareness-store.d.ts.map +1 -1
- package/dist/collab/awareness-store.js +19 -12
- package/dist/collab/awareness-store.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/client.d.ts.map +1 -1
- package/dist/collab/client.js +13 -5
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/index.d.ts +1 -0
- package/dist/collab/index.d.ts.map +1 -1
- package/dist/collab/index.js +1 -0
- package/dist/collab/index.js.map +1 -1
- package/dist/collab/recent-edits.d.ts +3 -1
- package/dist/collab/recent-edits.d.ts.map +1 -1
- package/dist/collab/recent-edits.js +57 -2
- package/dist/collab/recent-edits.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.d.ts.map +1 -1
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +87 -10
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/builder-design-systems.d.ts +15 -0
- package/dist/server/builder-design-systems.d.ts.map +1 -1
- package/dist/server/builder-design-systems.js +9 -4
- package/dist/server/builder-design-systems.js.map +1 -1
- 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, "&")
|
|
167
|
+
.replace(/"/g, """)
|
|
168
|
+
.replace(/</g, "<")
|
|
169
|
+
.replace(/>/g, ">");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function escapeXmlText(value: string): string {
|
|
173
|
+
return String(value)
|
|
174
|
+
.replace(/&/g, "&")
|
|
175
|
+
.replace(/</g, "<")
|
|
176
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|