@agent-native/core 0.80.10 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +53 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/cli/design-connect.ts +419 -19
  6. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  7. package/corpus/core/src/client/ConnectBuilderCard.tsx +1 -0
  8. package/corpus/core/src/client/chat/index.ts +1 -0
  9. package/corpus/core/src/client/chat/run-recovery.tsx +9 -7
  10. package/corpus/core/src/client/chat-view-transition.ts +33 -2
  11. package/corpus/core/src/client/composer/PromptComposer.tsx +21 -2
  12. package/corpus/core/src/client/index.ts +1 -0
  13. package/corpus/core/src/client/org/OrgSwitcher.tsx +26 -4
  14. package/corpus/core/src/client/sharing/ShareButton.tsx +3 -3
  15. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  16. package/corpus/core/src/client/use-agent-chat-home-handoff.ts +31 -3
  17. package/corpus/core/src/db/client.ts +29 -2
  18. package/corpus/core/src/provider-api/index.ts +29 -0
  19. package/corpus/core/src/server/core-routes-plugin.ts +58 -20
  20. package/corpus/core/src/styles/agent-native.css +54 -0
  21. package/corpus/core/src/usage/store.ts +2 -2
  22. package/corpus/templates/analytics/app/components/layout/Layout.tsx +118 -10
  23. package/corpus/templates/analytics/app/components/layout/layout-route-policy.ts +7 -0
  24. package/corpus/templates/analytics/app/global.css +8 -0
  25. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +7 -1
  26. package/corpus/templates/analytics/app/lib/chat-handoff.ts +34 -0
  27. package/corpus/templates/analytics/app/pages/Ask.tsx +9 -18
  28. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +2 -2
  29. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +109 -21
  30. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +4 -6
  31. package/corpus/templates/analytics/changelog/2026-06-30-ask-opens-a-fresh-chat-unless-you-were-just-chatting-and-das.md +6 -0
  32. package/corpus/templates/analytics/changelog/2026-06-30-dashboard-email-reports-are-more-reliable-and-use-cleaner.md +6 -0
  33. package/corpus/templates/analytics/changelog/2026-06-30-dashboard-toolbar-controls-use-a-quieter-share-button-and-ve.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-06-30-session-replay-rows-show-whole-minute-durations-in-the-playl.md +6 -0
  35. package/corpus/templates/analytics/changelog/2026-06-30-session-replays-now-render-captured-styling-and-keep-the-eve.md +6 -0
  36. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +16 -3
  37. package/corpus/templates/analytics/server/lib/dashboard-report.ts +31 -14
  38. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  39. package/corpus/templates/assets/AGENTS.md +4 -0
  40. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  41. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  42. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  43. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  44. package/corpus/templates/assets/actions/open-asset-picker.ts +9 -0
  45. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  46. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  47. package/corpus/templates/assets/app/global.css +15 -0
  48. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +4 -0
  49. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  50. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  51. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  52. package/corpus/templates/assets/app/routes/library.tsx +233 -52
  53. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  54. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  55. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  56. package/corpus/templates/assets/changelog/2026-06-30-the-assets-picker-now-supports-a-vertical-embedded-layout-fo.md +6 -0
  57. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  58. package/corpus/templates/assets/shared/api.ts +4 -0
  59. package/corpus/templates/clips/actions/finalize-recording.ts +1 -12
  60. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  61. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  62. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +10 -5
  63. package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -8
  64. package/corpus/templates/clips/changelog/2026-06-30-extension-save-recovery-avoids-false-upload-failures.md +6 -0
  65. package/corpus/templates/clips/changelog/2026-06-30-longer-chrome-extension-recordings-now-keep-saving-reliably-.md +6 -0
  66. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  67. package/corpus/templates/clips/chrome-extension/src/background.ts +19 -10
  68. package/corpus/templates/clips/chrome-extension/src/content-script.ts +38 -12
  69. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +224 -0
  70. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +119 -16
  71. package/corpus/templates/clips/chrome-extension/src/overlay.ts +16 -0
  72. package/corpus/templates/clips/server/lib/recording-upload-state.ts +48 -0
  73. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +9 -1
  74. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +63 -13
  75. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +74 -0
  76. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +32 -0
  77. package/corpus/templates/design/AGENTS.md +27 -0
  78. package/corpus/templates/design/CODE_NATIVE_DESIGN_STUDIO_IMPLEMENTATION_PLAN.md +1 -1
  79. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +5 -5
  80. package/corpus/templates/design/actions/apply-component-prop-edit.ts +76 -25
  81. package/corpus/templates/design/actions/apply-design-state.ts +17 -4
  82. package/corpus/templates/design/actions/apply-motion-edit.ts +76 -115
  83. package/corpus/templates/design/actions/apply-shader-fill.ts +148 -56
  84. package/corpus/templates/design/actions/capture-design-state.ts +27 -4
  85. package/corpus/templates/design/actions/connect-localhost.ts +59 -22
  86. package/corpus/templates/design/actions/delete-design.ts +20 -0
  87. package/corpus/templates/design/actions/export-zip.ts +23 -8
  88. package/corpus/templates/design/actions/generate-design.ts +53 -1
  89. package/corpus/templates/design/actions/get-component-details.ts +21 -26
  90. package/corpus/templates/design/actions/get-motion-timeline.ts +135 -10
  91. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +116 -0
  92. package/corpus/templates/design/actions/import-design-tokens.ts +521 -0
  93. package/corpus/templates/design/actions/index-design-tokens.ts +18 -5
  94. package/corpus/templates/design/actions/insert-design-native-asset.ts +268 -0
  95. package/corpus/templates/design/actions/insert-figma-library-asset.ts +233 -0
  96. package/corpus/templates/design/actions/list-design-extensions.ts +32 -10
  97. package/corpus/templates/design/actions/list-design-native-assets.ts +105 -0
  98. package/corpus/templates/design/actions/list-design-states.ts +19 -1
  99. package/corpus/templates/design/actions/list-figma-library-assets.ts +292 -0
  100. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +238 -0
  101. package/corpus/templates/design/actions/navigate.ts +18 -3
  102. package/corpus/templates/design/actions/open-component-source.ts +7 -2
  103. package/corpus/templates/design/actions/preview-component-prop-edit.ts +32 -26
  104. package/corpus/templates/design/actions/preview-shader-fill.ts +9 -11
  105. package/corpus/templates/design/actions/provider-api-catalog.ts +2 -2
  106. package/corpus/templates/design/actions/provider-api-docs.ts +1 -1
  107. package/corpus/templates/design/actions/provider-api-request.ts +3 -3
  108. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +48 -0
  109. package/corpus/templates/design/actions/run-design-audit.ts +74 -10
  110. package/corpus/templates/design/actions/run-design-extension-action.ts +1 -1
  111. package/corpus/templates/design/actions/view-screen.ts +1 -1
  112. package/corpus/templates/design/actions/write-local-file.ts +213 -0
  113. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +244 -3197
  114. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +30 -20
  115. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +795 -289
  116. package/corpus/templates/design/app/components/design/EditPanel.tsx +635 -234
  117. package/corpus/templates/design/app/components/design/LayersPanel.tsx +124 -32
  118. package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +161 -0
  119. package/corpus/templates/design/app/components/design/MotionDock.tsx +244 -266
  120. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +491 -56
  121. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +55 -67
  122. package/corpus/templates/design/app/components/design/StatesPanel.tsx +38 -63
  123. package/corpus/templates/design/app/components/design/TokensPanel.tsx +264 -8
  124. package/corpus/templates/design/app/components/design/bridge/codegen.ts +125 -0
  125. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +4037 -0
  126. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +62 -0
  127. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +272 -0
  128. package/corpus/templates/design/app/components/design/bridge/nav.bridge.ts +116 -0
  129. package/corpus/templates/design/app/components/design/bridge/sample.bridge.ts +24 -0
  130. package/corpus/templates/design/app/components/design/bridge/shader-fill-preview.bridge.ts +78 -0
  131. package/corpus/templates/design/app/components/design/bridge/tsconfig.json +17 -0
  132. package/corpus/templates/design/app/components/design/bridge/tweak.bridge.ts +28 -0
  133. package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +43 -0
  134. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +45 -16
  135. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +88 -9
  136. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +94 -9
  137. package/corpus/templates/design/app/components/layout/Layout.tsx +19 -3
  138. package/corpus/templates/design/app/components/layout/Sidebar.tsx +1 -1
  139. package/corpus/templates/design/app/global.css +7 -0
  140. package/corpus/templates/design/app/hooks/use-navigation-state.ts +23 -0
  141. package/corpus/templates/design/app/i18n/zh-TW.ts +22 -0
  142. package/corpus/templates/design/app/i18n-data.ts +369 -0
  143. package/corpus/templates/design/app/lib/pending-generation.ts +1 -0
  144. package/corpus/templates/design/app/pages/DesignEditor.tsx +3024 -616
  145. package/corpus/templates/design/app/pages/Index.tsx +5 -1
  146. package/corpus/templates/design/changelog/2026-06-30-applying-the-one-click-fix-for-a-small-tap-target-accessibil.md +6 -0
  147. package/corpus/templates/design/changelog/2026-06-30-canvas-creation-screen-dragging-undo-redo-and-image-fill-edi.md +6 -0
  148. package/corpus/templates/design/changelog/2026-06-30-component-prop-edits-and-shader-fills-now-preserve-rapid-sam.md +6 -0
  149. package/corpus/templates/design/changelog/2026-06-30-design-can-browse-figma-library-components-and-insert-them-i.md +6 -0
  150. package/corpus/templates/design/changelog/2026-06-30-design-now-has-a-figma-style-left-rail-for-file-agent-assets.md +6 -0
  151. package/corpus/templates/design/changelog/2026-06-30-design-now-has-native-components-embedded-media-and-figma-im.md +6 -0
  152. package/corpus/templates/design/changelog/2026-06-30-design-preview-now-includes-a-publish-app-waitlist-option-fr.md +6 -0
  153. package/corpus/templates/design/changelog/2026-06-30-design-s-left-rail-now-matches-the-sidebar-chrome-and-uses-t.md +6 -0
  154. package/corpus/templates/design/changelog/2026-06-30-design-s-tools-create-menu-now-stays-compact-instead-of-rend.md +6 -0
  155. package/corpus/templates/design/changelog/2026-06-30-design-starter-prompts-now-begin-generating-immediately-and-.md +6 -0
  156. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  157. package/corpus/templates/design/changelog/2026-06-30-design-tools-now-use-a-quieter-figma-style-list-layout.md +6 -0
  158. package/corpus/templates/design/changelog/2026-06-30-drawing-rectangles-on-the-all-screens-canvas-no-longer-flash.md +6 -0
  159. package/corpus/templates/design/changelog/2026-06-30-fixed-code-native-design-studio-edits-so-component-props-mot.md +6 -0
  160. package/corpus/templates/design/changelog/2026-06-30-fixed-visual-editing-hover-overlays-and-spacing-handles-so-s.md +6 -0
  161. package/corpus/templates/design/changelog/2026-06-30-motion-edits-now-save-automatically-without-a-separate-css-a.md +6 -0
  162. package/corpus/templates/design/changelog/2026-06-30-motion-timelines-now-reopen-as-editable-tracks-after-writing.md +6 -0
  163. package/corpus/templates/design/changelog/2026-06-30-new-design-prompts-with-uploaded-screenshots-work-reliably-o.md +6 -0
  164. package/corpus/templates/design/changelog/2026-06-30-screen-previews-in-all-screens-view-now-keep-rounded-outline.md +6 -0
  165. package/corpus/templates/design/changelog/2026-06-30-selection-outlines-now-sit-flush-on-rectangles-drawn-directl.md +6 -0
  166. package/corpus/templates/design/changelog/2026-06-30-share-options-use-a-tighter-design-editor-popover-with-compa.md +6 -0
  167. package/corpus/templates/design/changelog/2026-06-30-the-collapsed-motion-panel-now-stays-tucked-under-the-left-s.md +6 -0
  168. package/corpus/templates/design/changelog/2026-06-30-the-design-inspector-now-shows-states-and-review-in-a-tighte.md +6 -0
  169. package/corpus/templates/design/changelog/2026-06-30-the-sidebar-keeps-the-organization-picker-space-stable-while.md +6 -0
  170. package/corpus/templates/design/changelog/2026-06-30-tokens-can-now-be-imported-from-pasted-notes-code-files-fold.md +6 -0
  171. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  172. package/corpus/templates/design/e2e/helpers.ts +168 -65
  173. package/corpus/templates/design/package.json +3 -1
  174. package/corpus/templates/design/server/db/schema.ts +23 -0
  175. package/corpus/templates/design/server/handlers/uploads.ts +22 -1
  176. package/corpus/templates/design/server/lib/provider-api.ts +7 -4
  177. package/corpus/templates/design/server/lib/verify-write-grant.ts +108 -0
  178. package/corpus/templates/design/server/plugins/db.ts +39 -0
  179. package/corpus/templates/design/server/register-secrets.ts +38 -0
  180. package/corpus/templates/design/shared/board-file.ts +228 -0
  181. package/corpus/templates/design/shared/board-objects.ts +288 -0
  182. package/corpus/templates/design/shared/code-layer.ts +52 -0
  183. package/corpus/templates/design/shared/component-model.ts +35 -0
  184. package/corpus/templates/design/shared/motion-compiler.ts +137 -15
  185. package/corpus/templates/design/shared/motion-timeline.ts +2 -2
  186. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  187. package/corpus/templates/design/tsconfig.json +2 -1
  188. package/corpus/templates/slides/AGENTS.md +9 -0
  189. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +4 -0
  190. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +4 -24
  191. package/corpus/templates/slides/app/lib/export-pdf-client.ts +29 -20
  192. package/corpus/templates/slides/app/lib/export-pptx-client.ts +365 -0
  193. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -9
  194. package/corpus/templates/slides/app/vite-env.d.ts +18 -0
  195. package/corpus/templates/slides/changelog/2026-06-30-powerpoint-downloads-now-preserve-the-rendered-slide-layout-.md +6 -0
  196. package/corpus/templates/slides/changelog/2026-06-30-unavailable-decks-open-in-a-cleaner-full-height-centered-sta.md +6 -0
  197. package/corpus/templates/slides/package.json +1 -0
  198. package/corpus/templates/slides/vite.config.ts +1 -0
  199. package/dist/agent/production-agent.d.ts.map +1 -1
  200. package/dist/agent/production-agent.js +94 -10
  201. package/dist/agent/production-agent.js.map +1 -1
  202. package/dist/cli/design-connect.d.ts +25 -0
  203. package/dist/cli/design-connect.d.ts.map +1 -1
  204. package/dist/cli/design-connect.js +357 -18
  205. package/dist/cli/design-connect.js.map +1 -1
  206. package/dist/client/AssistantChat.d.ts +14 -0
  207. package/dist/client/AssistantChat.d.ts.map +1 -1
  208. package/dist/client/AssistantChat.js +115 -22
  209. package/dist/client/AssistantChat.js.map +1 -1
  210. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  211. package/dist/client/ConnectBuilderCard.js +1 -0
  212. package/dist/client/ConnectBuilderCard.js.map +1 -1
  213. package/dist/client/chat/index.d.ts +1 -1
  214. package/dist/client/chat/index.d.ts.map +1 -1
  215. package/dist/client/chat/index.js +1 -1
  216. package/dist/client/chat/index.js.map +1 -1
  217. package/dist/client/chat/run-recovery.d.ts.map +1 -1
  218. package/dist/client/chat/run-recovery.js +5 -5
  219. package/dist/client/chat/run-recovery.js.map +1 -1
  220. package/dist/client/chat-view-transition.d.ts +6 -0
  221. package/dist/client/chat-view-transition.d.ts.map +1 -1
  222. package/dist/client/chat-view-transition.js +23 -2
  223. package/dist/client/chat-view-transition.js.map +1 -1
  224. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  225. package/dist/client/composer/PromptComposer.js +3 -2
  226. package/dist/client/composer/PromptComposer.js.map +1 -1
  227. package/dist/client/index.d.ts +1 -1
  228. package/dist/client/index.d.ts.map +1 -1
  229. package/dist/client/index.js +1 -1
  230. package/dist/client/index.js.map +1 -1
  231. package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
  232. package/dist/client/org/OrgSwitcher.js +11 -4
  233. package/dist/client/org/OrgSwitcher.js.map +1 -1
  234. package/dist/client/sharing/ShareButton.js +3 -3
  235. package/dist/client/sharing/ShareButton.js.map +1 -1
  236. package/dist/client/sse-event-processor.d.ts.map +1 -1
  237. package/dist/client/sse-event-processor.js +10 -0
  238. package/dist/client/sse-event-processor.js.map +1 -1
  239. package/dist/client/use-agent-chat-home-handoff.d.ts +8 -2
  240. package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
  241. package/dist/client/use-agent-chat-home-handoff.js +20 -6
  242. package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
  243. package/dist/collab/awareness.d.ts +2 -2
  244. package/dist/collab/awareness.d.ts.map +1 -1
  245. package/dist/collab/routes.d.ts +2 -2
  246. package/dist/db/client.d.ts +1 -0
  247. package/dist/db/client.d.ts.map +1 -1
  248. package/dist/db/client.js +28 -2
  249. package/dist/db/client.js.map +1 -1
  250. package/dist/notifications/routes.d.ts +3 -3
  251. package/dist/observability/routes.d.ts +3 -3
  252. package/dist/progress/routes.d.ts +1 -1
  253. package/dist/provider-api/index.d.ts +5 -4
  254. package/dist/provider-api/index.d.ts.map +1 -1
  255. package/dist/provider-api/index.js +28 -0
  256. package/dist/provider-api/index.js.map +1 -1
  257. package/dist/resources/handlers.d.ts +3 -3
  258. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  259. package/dist/server/core-routes-plugin.d.ts +25 -0
  260. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  261. package/dist/server/core-routes-plugin.js +43 -18
  262. package/dist/server/core-routes-plugin.js.map +1 -1
  263. package/dist/server/transcribe-voice.d.ts +1 -1
  264. package/dist/styles/agent-native.css +54 -0
  265. package/dist/usage/store.js +2 -2
  266. package/dist/usage/store.js.map +1 -1
  267. package/package.json +1 -1
@@ -30,7 +30,12 @@ export type TweakSelections = Record<string, string | number | boolean>;
30
30
  *
31
31
  * Tweaks without a `cssVar` are skipped (they don't map to a property).
32
32
  * A missing selection falls back to the tweak's `defaultValue`.
33
+ * Selection keys that are themselves safe CSS custom properties are also
34
+ * emitted directly, which lets token edits persist vars that were not part of
35
+ * the generated tweak definition list.
33
36
  */
37
+ const CSS_CUSTOM_PROPERTY_NAME = /^--[-_a-zA-Z0-9]+$/;
38
+
34
39
  export function resolveTweaksToCssVars(
35
40
  tweaks: TweakDefinition[],
36
41
  selections: TweakSelections,
@@ -39,19 +44,37 @@ export function resolveTweaksToCssVars(
39
44
  for (const t of tweaks) {
40
45
  if (!t.cssVar) continue;
41
46
  const v = selections[t.id] ?? t.defaultValue;
42
- if (typeof v === "boolean") {
43
- out[t.cssVar] = v ? "1" : "0";
44
- } else if (typeof v === "number") {
45
- const unit =
46
- t.unit ?? (t.cssVar.toLowerCase().includes("radius") ? "px" : "");
47
- out[t.cssVar] = `${v}${unit}`;
48
- } else {
49
- out[t.cssVar] = String(v);
50
- }
47
+ out[t.cssVar] = stringifyCssVarValue(t.cssVar, v, t.unit);
48
+ }
49
+
50
+ for (const [key, value] of Object.entries(selections)) {
51
+ if (!isDirectCssVarSelectionKey(key)) continue;
52
+ out[key] = stringifyCssVarValue(key, value);
51
53
  }
54
+
52
55
  return out;
53
56
  }
54
57
 
58
+ export function isDirectCssVarSelectionKey(key: string): boolean {
59
+ return CSS_CUSTOM_PROPERTY_NAME.test(key);
60
+ }
61
+
62
+ function stringifyCssVarValue(
63
+ cssVar: string,
64
+ value: string | number | boolean,
65
+ unit?: string,
66
+ ): string {
67
+ if (typeof value === "boolean") {
68
+ return value ? "1" : "0";
69
+ }
70
+ if (typeof value === "number") {
71
+ const resolvedUnit =
72
+ unit ?? (cssVar.toLowerCase().includes("radius") ? "px" : "");
73
+ return `${value}${resolvedUnit}`;
74
+ }
75
+ return String(value);
76
+ }
77
+
55
78
  /**
56
79
  * Render resolved CSS vars as a `:root { ... }` block. Used by the
57
80
  * coding-handoff bundle so external agents inherit the user's tuned tokens
@@ -17,5 +17,6 @@
17
17
  "react-router.config.ts",
18
18
  ".react-router/types/**/*",
19
19
  ".generated/**/*"
20
- ]
20
+ ],
21
+ "exclude": ["node_modules", "dist", "app/**/*.bridge.ts"]
21
22
  }
@@ -54,6 +54,15 @@ extending the editor's save path, enqueue a granular op (`patch-slide`,
54
54
  - `navigate` moves the UI to decks, slides, imports, and exports.
55
55
  - Use app actions for full deck/slide data instead of relying on ambient context.
56
56
 
57
+ ## Export Behavior
58
+
59
+ - Browser PowerPoint export uses the rendered slide DOM to generate native,
60
+ editable PPTX text/shapes/images. Do not replace it with full-slide images
61
+ unless the user explicitly asks for non-editable visual snapshots.
62
+ - Google Slides export is a PPTX import workflow: generate the same editable
63
+ PPTX and have the user import it into Google Slides. Creating a native Google
64
+ Slides file directly requires a separate Google Slides API batchUpdate path.
65
+
57
66
  ## Skills
58
67
 
59
68
  Read the relevant skill before deeper work:
@@ -129,6 +129,8 @@ interface EditorToolbarProps {
129
129
  onDuplicateDeck?: () => void;
130
130
  /** Export the deck as PDF */
131
131
  onExportPdf?: () => void;
132
+ /** Export the deck as PPTX */
133
+ onExportPptx?: () => Promise<void> | void;
132
134
  /** Active deck aspect ratio (defaults to 16:9 when omitted) */
133
135
  aspectRatio?: AspectRatio;
134
136
  /** Change the deck's aspect ratio */
@@ -251,6 +253,7 @@ export default function EditorToolbar({
251
253
  onTogglePinMode,
252
254
  onDuplicateDeck,
253
255
  onExportPdf,
256
+ onExportPptx,
254
257
  aspectRatio,
255
258
  onSetAspectRatio,
256
259
  designSystemTitle,
@@ -929,6 +932,7 @@ graph TD
929
932
  deckTitle={deckTitle}
930
933
  onDuplicate={onDuplicateDeck ?? (() => {})}
931
934
  onExportPdf={onExportPdf ?? (() => {})}
935
+ onExportPptx={onExportPptx ?? (() => {})}
932
936
  />
933
937
  </div>
934
938
 
@@ -23,6 +23,7 @@ interface ExportMenuProps {
23
23
  deckTitle: string;
24
24
  onDuplicate: () => void;
25
25
  onExportPdf: () => void;
26
+ onExportPptx: () => Promise<void> | void;
26
27
  onShareLink?: () => void;
27
28
  onShareTeam?: () => void;
28
29
  }
@@ -32,6 +33,7 @@ export function ExportMenu({
32
33
  deckTitle,
33
34
  onDuplicate,
34
35
  onExportPdf,
36
+ onExportPptx,
35
37
  onShareLink,
36
38
  onShareTeam,
37
39
  }: ExportMenuProps) {
@@ -66,30 +68,9 @@ export function ExportMenu({
66
68
  }
67
69
  };
68
70
 
69
- const fetchPptxExport = async () => {
70
- const res = await fetch(`${appBasePath()}/api/exports/pptx`, {
71
- method: "POST",
72
- headers: { "Content-Type": "application/json" },
73
- body: JSON.stringify({ deckId }),
74
- });
75
- if (!res.ok) {
76
- throw new Error(
77
- await readErrorMessage(res, t("editorExport.pptxFailed")),
78
- );
79
- }
80
- return {
81
- blob: await res.blob(),
82
- filename: filenameFromDisposition(
83
- res.headers.get("content-disposition"),
84
- ".pptx",
85
- ),
86
- };
87
- };
88
-
89
71
  const handleExportPptx = async () => {
90
72
  try {
91
- const { blob, filename } = await fetchPptxExport();
92
- triggerBlobDownload(blob, filename);
73
+ await onExportPptx();
93
74
  } catch (err) {
94
75
  console.error("Export failed:", err);
95
76
  toast({
@@ -105,8 +86,7 @@ export function ExportMenu({
105
86
 
106
87
  const handleExportGoogleSlides = async () => {
107
88
  try {
108
- const { blob, filename } = await fetchPptxExport();
109
- triggerBlobDownload(blob, filename);
89
+ await onExportPptx();
110
90
  toast({
111
91
  title: t("editorExport.googleSlidesDownloaded"),
112
92
  description: t("editorExport.googleSlidesImportHint"),
@@ -20,7 +20,7 @@ import { type AspectRatio, getAspectRatioDims } from "./aspect-ratios";
20
20
  * setting the attribute and re-assigning the same src. This is the root
21
21
  * cause of the "blank images in exported PDF" bug Rochkind reported.
22
22
  */
23
- async function preloadImagesWithCors(root: HTMLElement): Promise<void> {
23
+ export async function preloadImagesWithCors(root: HTMLElement): Promise<void> {
24
24
  const imgs = Array.from(root.querySelectorAll<HTMLImageElement>("img"));
25
25
  await Promise.all(
26
26
  imgs.map(async (img) => {
@@ -60,6 +60,33 @@ async function preloadImagesWithCors(root: HTMLElement): Promise<void> {
60
60
  );
61
61
  }
62
62
 
63
+ export function findSlideExportSource(
64
+ slideId: string,
65
+ slideIndex: number,
66
+ slideCount: number,
67
+ ): HTMLElement {
68
+ const candidates = Array.from(
69
+ document.querySelectorAll<HTMLElement>(
70
+ `[data-slide-canvas="${CSS.escape(slideId)}"]`,
71
+ ),
72
+ );
73
+ // Don't silently drop missing slides — a collapsed sidebar (mobile
74
+ // default) would otherwise produce a partial export with no warning.
75
+ if (candidates.length === 0) {
76
+ throw new Error(
77
+ `Slide ${slideIndex + 1} of ${slideCount} is not currently rendered. Open the slide sidebar and try again.`,
78
+ );
79
+ }
80
+
81
+ // A given slide can appear multiple times (sidebar thumbnail + active
82
+ // editor canvas); pick the one with the largest natural width so we
83
+ // capture full-resolution pixels even when the visible copy is scaled
84
+ // down via CSS transform.
85
+ return candidates.reduce((best, el) =>
86
+ el.offsetWidth > best.offsetWidth ? el : best,
87
+ );
88
+ }
89
+
63
90
  export async function exportDeckAsPdf(
64
91
  deckTitle: string,
65
92
  slideIds: string[],
@@ -99,25 +126,7 @@ export async function exportDeckAsPdf(
99
126
 
100
127
  for (let i = 0; i < slideIds.length; i++) {
101
128
  const slideId = slideIds[i];
102
- // A given slide can appear multiple times (sidebar thumbnail + active
103
- // editor canvas); pick the one with the largest natural width so we
104
- // capture full-resolution pixels even when the visible copy is scaled
105
- // down via CSS transform.
106
- const candidates = Array.from(
107
- document.querySelectorAll<HTMLElement>(
108
- `[data-slide-canvas="${CSS.escape(slideId)}"]`,
109
- ),
110
- );
111
- // Don't silently drop missing slides — a collapsed sidebar (mobile
112
- // default) would otherwise produce a partial PDF with no warning.
113
- if (candidates.length === 0) {
114
- throw new Error(
115
- `Slide ${i + 1} of ${slideIds.length} is not currently rendered. Open the slide sidebar and try again.`,
116
- );
117
- }
118
- const source = candidates.reduce((best, el) =>
119
- el.offsetWidth > best.offsetWidth ? el : best,
120
- );
129
+ const source = findSlideExportSource(slideId, i, slideIds.length);
121
130
 
122
131
  // Force CORS-enabled re-fetch on every cross-origin <img> before
123
132
  // capture — otherwise the canvas tainting check inside modern-screenshot
@@ -0,0 +1,365 @@
1
+ import { type AspectRatio, getAspectRatioDims } from "./aspect-ratios";
2
+ import {
3
+ findSlideExportSource,
4
+ preloadImagesWithCors,
5
+ } from "./export-pdf-client";
6
+
7
+ interface PptxExportSlide {
8
+ id: string;
9
+ notes?: string;
10
+ }
11
+
12
+ function safePptxName(title: string) {
13
+ const safeName = title.replace(/[^a-zA-Z0-9]/g, "-") || "deck";
14
+ return `${safeName}.pptx`;
15
+ }
16
+
17
+ function triggerBlobDownload(blob: Blob, filename: string) {
18
+ const url = URL.createObjectURL(blob);
19
+ const a = document.createElement("a");
20
+ a.href = url;
21
+ a.download = filename;
22
+ a.rel = "noopener";
23
+ document.body.appendChild(a);
24
+ a.click();
25
+ a.remove();
26
+ window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
27
+ }
28
+
29
+ function escapeXml(value: string) {
30
+ return value
31
+ .replace(/&/g, "&amp;")
32
+ .replace(/</g, "&lt;")
33
+ .replace(/>/g, "&gt;")
34
+ .replace(/"/g, "&quot;")
35
+ .replace(/'/g, "&apos;");
36
+ }
37
+
38
+ function addRelationship(xml: string, relationship: string) {
39
+ if (xml.includes(relationship)) return xml;
40
+ return xml.replace("</Relationships>", `${relationship}</Relationships>`);
41
+ }
42
+
43
+ function addContentTypeOverride(xml: string, partName: string, type: string) {
44
+ if (xml.includes(`PartName="${partName}"`)) return xml;
45
+ return xml.replace(
46
+ "</Types>",
47
+ `<Override PartName="${partName}" ContentType="${type}"/></Types>`,
48
+ );
49
+ }
50
+
51
+ function nextRelationshipId(xml: string) {
52
+ const ids = Array.from(xml.matchAll(/\bId="rId(\d+)"/g)).map((match) =>
53
+ Number(match[1]),
54
+ );
55
+ return `rId${Math.max(0, ...ids) + 1}`;
56
+ }
57
+
58
+ function notesTextBody(notes: string) {
59
+ const lines = notes.split(/\r?\n/);
60
+ return lines
61
+ .map(
62
+ (line) =>
63
+ `<a:p><a:r><a:rPr lang="en-US" dirty="0"/><a:t>${escapeXml(line)}</a:t></a:r><a:endParaRPr lang="en-US" dirty="0"/></a:p>`,
64
+ )
65
+ .join("");
66
+ }
67
+
68
+ function notesSlideXml(notes: string, slideNumber: number) {
69
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
70
+ <p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/>${notesTextBody(notes)}</p:txBody></p:sp><p:sp><p:nvSpPr><p:cNvPr id="4" name="Slide Number Placeholder 3"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldNum" sz="quarter" idx="10"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:fld id="{F7021451-1387-4CA6-816F-3879F97B5CBC}" type="slidenum"><a:rPr lang="en-US"/><a:t>${slideNumber}</a:t></a:fld><a:endParaRPr lang="en-US"/></a:p></p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>`;
71
+ }
72
+
73
+ const NOTES_MASTER_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
74
+ <p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:notesStyle><a:lvl1pPr marL="0" algn="l" defTabSz="914400" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1200" kern="1200"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl1pPr></p:notesStyle></p:notesMaster>`;
75
+
76
+ const NOTES_MASTER_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
77
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/></Relationships>`;
78
+
79
+ export async function addSpeakerNotesToPptxBlob(
80
+ blob: Blob,
81
+ slides: PptxExportSlide[],
82
+ ): Promise<Blob> {
83
+ const hasNotes = slides.some((slide) => slide.notes?.trim());
84
+ if (!hasNotes) return blob;
85
+
86
+ const { default: JSZip } = await import("jszip");
87
+ const zip = await JSZip.loadAsync(blob);
88
+
89
+ const contentTypesFile = zip.file("[Content_Types].xml");
90
+ const presentationFile = zip.file("ppt/presentation.xml");
91
+ const presentationRelsFile = zip.file("ppt/_rels/presentation.xml.rels");
92
+
93
+ if (!contentTypesFile || !presentationFile || !presentationRelsFile) {
94
+ return blob;
95
+ }
96
+
97
+ let contentTypes = await contentTypesFile.async("string");
98
+ let presentationXml = await presentationFile.async("string");
99
+ let presentationRels = await presentationRelsFile.async("string");
100
+
101
+ if (!zip.file("ppt/notesMasters/notesMaster1.xml")) {
102
+ zip.file("ppt/notesMasters/notesMaster1.xml", NOTES_MASTER_XML);
103
+ }
104
+ if (!zip.file("ppt/notesMasters/_rels/notesMaster1.xml.rels")) {
105
+ zip.file(
106
+ "ppt/notesMasters/_rels/notesMaster1.xml.rels",
107
+ NOTES_MASTER_RELS_XML,
108
+ );
109
+ }
110
+
111
+ contentTypes = addContentTypeOverride(
112
+ contentTypes,
113
+ "/ppt/notesMasters/notesMaster1.xml",
114
+ "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml",
115
+ );
116
+
117
+ if (!presentationRels.includes("relationships/notesMaster")) {
118
+ const relId = nextRelationshipId(presentationRels);
119
+ presentationRels = addRelationship(
120
+ presentationRels,
121
+ `<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="notesMasters/notesMaster1.xml"/>`,
122
+ );
123
+ if (!presentationXml.includes("<p:notesMasterIdLst>")) {
124
+ presentationXml = presentationXml.replace(
125
+ "</p:sldIdLst>",
126
+ `</p:sldIdLst><p:notesMasterIdLst><p:notesMasterId r:id="${relId}"/></p:notesMasterIdLst>`,
127
+ );
128
+ }
129
+ }
130
+
131
+ if (!presentationXml.includes("<p:notesSz")) {
132
+ presentationXml = presentationXml.replace(
133
+ "<p:defaultTextStyle>",
134
+ '<p:notesSz cx="6858000" cy="12192000"/><p:defaultTextStyle>',
135
+ );
136
+ }
137
+
138
+ for (let i = 0; i < slides.length; i++) {
139
+ const notes = slides[i].notes?.trim();
140
+ if (!notes) continue;
141
+
142
+ const slideNumber = i + 1;
143
+ const slideRelsPath = `ppt/slides/_rels/slide${slideNumber}.xml.rels`;
144
+ const slideRelsFile = zip.file(slideRelsPath);
145
+ if (!slideRelsFile) continue;
146
+
147
+ let slideRels = await slideRelsFile.async("string");
148
+ if (!slideRels.includes("relationships/notesSlide")) {
149
+ const relId = nextRelationshipId(slideRels);
150
+ slideRels = addRelationship(
151
+ slideRels,
152
+ `<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide${slideNumber}.xml"/>`,
153
+ );
154
+ zip.file(slideRelsPath, slideRels);
155
+ }
156
+
157
+ zip.file(
158
+ `ppt/notesSlides/notesSlide${slideNumber}.xml`,
159
+ notesSlideXml(notes, slideNumber),
160
+ );
161
+ zip.file(
162
+ `ppt/notesSlides/_rels/notesSlide${slideNumber}.xml.rels`,
163
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
164
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide${slideNumber}.xml"/></Relationships>`,
165
+ );
166
+ contentTypes = addContentTypeOverride(
167
+ contentTypes,
168
+ `/ppt/notesSlides/notesSlide${slideNumber}.xml`,
169
+ "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml",
170
+ );
171
+ }
172
+
173
+ zip.file("[Content_Types].xml", contentTypes);
174
+ zip.file("ppt/presentation.xml", presentationXml);
175
+ zip.file("ppt/_rels/presentation.xml.rels", presentationRels);
176
+
177
+ return zip.generateAsync({
178
+ type: "blob",
179
+ compression: "DEFLATE",
180
+ compressionOptions: { level: 6 },
181
+ });
182
+ }
183
+
184
+ function createUnscaledExportClone(
185
+ source: HTMLElement,
186
+ dims: { width: number; height: number },
187
+ ) {
188
+ const stage = document.createElement("div");
189
+ stage.setAttribute("aria-hidden", "true");
190
+ Object.assign(stage.style, {
191
+ height: `${dims.height}px`,
192
+ left: "-100000px",
193
+ overflow: "hidden",
194
+ pointerEvents: "none",
195
+ position: "fixed",
196
+ top: "0",
197
+ width: `${dims.width}px`,
198
+ zIndex: "-1",
199
+ });
200
+
201
+ const clone = source.cloneNode(true) as HTMLElement;
202
+ Object.assign(clone.style, {
203
+ height: `${dims.height}px`,
204
+ maxHeight: `${dims.height}px`,
205
+ maxWidth: `${dims.width}px`,
206
+ position: "relative",
207
+ transform: "none",
208
+ width: `${dims.width}px`,
209
+ });
210
+
211
+ stage.appendChild(clone);
212
+ document.body.appendChild(stage);
213
+
214
+ return {
215
+ element: clone,
216
+ cleanup: () => stage.remove(),
217
+ };
218
+ }
219
+
220
+ function svgDataUrl(svg: SVGSVGElement) {
221
+ const copy = svg.cloneNode(true) as SVGSVGElement;
222
+ if (!copy.getAttribute("xmlns")) {
223
+ copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
224
+ }
225
+ const serialized = new XMLSerializer().serializeToString(copy);
226
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
227
+ }
228
+
229
+ async function rasterizeSvgElement(
230
+ svg: SVGSVGElement,
231
+ width: number,
232
+ height: number,
233
+ ) {
234
+ const fallback = svgDataUrl(svg);
235
+ const canvas = document.createElement("canvas");
236
+ const ctx = canvas.getContext("2d");
237
+ if (!ctx || typeof Image === "undefined") return fallback;
238
+
239
+ const scale = Math.max(2, window.devicePixelRatio || 1);
240
+ canvas.width = Math.max(1, Math.ceil(width * scale));
241
+ canvas.height = Math.max(1, Math.ceil(height * scale));
242
+
243
+ const image = new Image();
244
+ const loaded = new Promise<void>((resolve, reject) => {
245
+ image.onload = () => resolve();
246
+ image.onerror = () => reject(new Error("Could not rasterize SVG"));
247
+ });
248
+ image.src = fallback;
249
+
250
+ try {
251
+ await loaded;
252
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
253
+ return canvas.toDataURL("image/png");
254
+ } catch {
255
+ return fallback;
256
+ }
257
+ }
258
+
259
+ async function replaceInlineSvgsWithImages(root: HTMLElement) {
260
+ const svgs = Array.from(root.querySelectorAll<SVGSVGElement>("svg"));
261
+ for (const svg of svgs) {
262
+ const rect = svg.getBoundingClientRect();
263
+ const viewBox = svg.viewBox?.baseVal;
264
+ const width =
265
+ rect.width || Number(svg.getAttribute("width")) || viewBox?.width || 1;
266
+ const height =
267
+ rect.height || Number(svg.getAttribute("height")) || viewBox?.height || 1;
268
+ const dataUrl = await rasterizeSvgElement(svg, width, height);
269
+ const img = document.createElement("img");
270
+ const style = window.getComputedStyle(svg);
271
+ img.src = dataUrl;
272
+ img.alt = svg.getAttribute("aria-label") ?? "";
273
+ Object.assign(img.style, {
274
+ alignSelf: style.alignSelf,
275
+ display: style.display === "inline" ? "inline-block" : style.display,
276
+ flex: style.flex,
277
+ height: `${height}px`,
278
+ justifySelf: style.justifySelf,
279
+ left: style.left,
280
+ marginBottom: style.marginBottom,
281
+ marginLeft: style.marginLeft,
282
+ marginRight: style.marginRight,
283
+ marginTop: style.marginTop,
284
+ objectFit: "contain",
285
+ opacity: style.opacity,
286
+ position: style.position,
287
+ right: style.right,
288
+ top: style.top,
289
+ transform: style.transform === "none" ? "" : style.transform,
290
+ width: `${width}px`,
291
+ zIndex: style.zIndex,
292
+ });
293
+ svg.replaceWith(img);
294
+ }
295
+ }
296
+
297
+ function widenNoWrapTextElements(root: HTMLElement) {
298
+ const elements = Array.from(root.querySelectorAll<HTMLElement>("*"));
299
+ for (const element of elements) {
300
+ if (!element.textContent?.trim()) continue;
301
+ if (element.querySelector("img,svg,video,canvas")) continue;
302
+ const style = window.getComputedStyle(element);
303
+ if (style.whiteSpace !== "nowrap" && style.whiteSpace !== "pre") continue;
304
+ const rect = element.getBoundingClientRect();
305
+ if (!rect.width || !rect.height) continue;
306
+ const buffer = Math.max(24, rect.width * 0.25);
307
+ element.style.boxSizing = "border-box";
308
+ if (style.display === "inline") {
309
+ element.style.display = "inline-block";
310
+ }
311
+ element.style.width = `${Math.ceil(rect.width + buffer)}px`;
312
+ }
313
+ }
314
+
315
+ export async function exportDeckAsPptx(
316
+ deckTitle: string,
317
+ slides: PptxExportSlide[],
318
+ aspectRatio?: AspectRatio,
319
+ ): Promise<void> {
320
+ const { exportToPptx } = await import("dom-to-pptx");
321
+
322
+ if (typeof document !== "undefined" && document.fonts?.ready) {
323
+ await document.fonts.ready;
324
+ }
325
+
326
+ const dims = getAspectRatioDims(aspectRatio);
327
+ const exportClones: Array<{
328
+ element: HTMLElement;
329
+ cleanup: () => void;
330
+ }> = [];
331
+
332
+ try {
333
+ for (let i = 0; i < slides.length; i++) {
334
+ const exportSlide = slides[i];
335
+ const source = findSlideExportSource(exportSlide.id, i, slides.length);
336
+ const clone = createUnscaledExportClone(source, {
337
+ width: dims.width,
338
+ height: dims.height,
339
+ });
340
+ exportClones.push(clone);
341
+ widenNoWrapTextElements(clone.element);
342
+ await replaceInlineSvgsWithImages(clone.element);
343
+ await preloadImagesWithCors(clone.element);
344
+ }
345
+
346
+ const initialBlob = await exportToPptx(
347
+ exportClones.map((clone) => clone.element),
348
+ {
349
+ autoEmbedFonts: true,
350
+ fileName: safePptxName(deckTitle),
351
+ height: dims.pptxInches.h,
352
+ skipDownload: true,
353
+ svgAsVector: false,
354
+ width: dims.pptxInches.w,
355
+ },
356
+ );
357
+
358
+ const blob = await addSpeakerNotesToPptxBlob(initialBlob, slides);
359
+ triggerBlobDownload(blob, safePptxName(deckTitle));
360
+ } finally {
361
+ for (const clone of exportClones) {
362
+ clone.cleanup();
363
+ }
364
+ }
365
+ }
@@ -65,6 +65,7 @@ import { toast } from "@/hooks/use-toast";
65
65
  import type { AspectRatio } from "@/lib/aspect-ratios";
66
66
  import { getPreset } from "@/lib/design-systems";
67
67
  import { exportDeckAsPdf } from "@/lib/export-pdf-client";
68
+ import { exportDeckAsPptx } from "@/lib/export-pptx-client";
68
69
  import {
69
70
  shouldClearNewDeckGeneratingState,
70
71
  shouldShowNewDeckGeneratingOverlay,
@@ -81,7 +82,6 @@ const Pinpoint = lazy<ComponentType<PinpointProps>>(() =>
81
82
  );
82
83
 
83
84
  function MissingDeckAccessPane({
84
- deckId,
85
85
  hasTeamJoinOption,
86
86
  orgLoading,
87
87
  orgError,
@@ -89,7 +89,6 @@ function MissingDeckAccessPane({
89
89
  onRetry,
90
90
  onBack,
91
91
  }: {
92
- deckId: string | undefined;
93
92
  hasTeamJoinOption: boolean;
94
93
  orgLoading: boolean;
95
94
  orgError: boolean;
@@ -116,7 +115,7 @@ function MissingDeckAccessPane({
116
115
  : t("deckEditor.deckUnavailableDescription");
117
116
 
118
117
  return (
119
- <div className="flex flex-1 items-center justify-center bg-background px-4 py-10">
118
+ <div className="flex min-h-screen items-center justify-center bg-background px-4 py-10">
120
119
  <div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-sm">
121
120
  <div className="mb-4 flex items-center gap-3">
122
121
  <div className="flex size-10 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
@@ -124,11 +123,6 @@ function MissingDeckAccessPane({
124
123
  </div>
125
124
  <div className="min-w-0">
126
125
  <h1 className="text-base font-semibold text-foreground">{title}</h1>
127
- {deckId && (
128
- <p className="truncate text-xs text-muted-foreground">
129
- Deck ID: {deckId}
130
- </p>
131
- )}
132
126
  </div>
133
127
  </div>
134
128
  <p className="text-sm leading-6 text-muted-foreground">{description}</p>
@@ -723,7 +717,6 @@ export default function DeckEditor() {
723
717
  if (!deck || !id) {
724
718
  return (
725
719
  <MissingDeckAccessPane
726
- deckId={id}
727
720
  hasTeamJoinOption={hasTeamJoinOption}
728
721
  orgLoading={orgLoading}
729
722
  orgError={orgError}
@@ -846,6 +839,16 @@ export default function DeckEditor() {
846
839
  });
847
840
  }
848
841
  }}
842
+ onExportPptx={async () => {
843
+ const slides = deck.slides.map((s) => ({
844
+ id: s.id,
845
+ notes: s.notes,
846
+ }));
847
+ if (slides.length === 0) {
848
+ throw new Error(t("deckEditor.deckHasNoSlides"));
849
+ }
850
+ await exportDeckAsPptx(deck.title, slides, deck.aspectRatio);
851
+ }}
849
852
  aspectRatio={deck.aspectRatio}
850
853
  designSystemTitle={designSystemTitle}
851
854
  onSetAspectRatio={(ratio: AspectRatio) => {