@agent-native/core 0.157.8 → 0.157.10

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 (154) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/purge-first-party-analytics-postgres.ts +26 -8
  3. package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +261 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
  5. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +75 -31
  6. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +4 -1
  7. package/corpus/templates/analytics/server/plugins/db.ts +139 -0
  8. package/corpus/templates/slides/AGENTS.md +14 -10
  9. package/corpus/templates/slides/actions/export-pptx.ts +1311 -121
  10. package/corpus/templates/slides/actions/import-file.ts +28 -1
  11. package/corpus/templates/slides/actions/import-pptx.ts +69 -42
  12. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
  13. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
  14. package/corpus/templates/slides/app/global.css +19 -1
  15. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
  16. package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
  17. package/corpus/templates/slides/app/lib/export-pptx-client.ts +601 -30
  18. package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
  19. package/corpus/templates/slides/server/handlers/import/html-converter.ts +1240 -22
  20. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +329 -64
  21. package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
  22. package/corpus/templates/slides/server/lib/source-import.ts +10 -1
  23. package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
  24. package/dist/agent/harness/migrations.d.ts +4 -0
  25. package/dist/agent/harness/migrations.js +60 -0
  26. package/dist/agent/production-agent.d.ts +2 -0
  27. package/dist/agent/production-agent.js +15 -0
  28. package/dist/agent/run-loop-with-resume.d.ts +1 -0
  29. package/dist/agent/run-loop-with-resume.js +44 -12
  30. package/dist/agent/run-migrations.d.ts +8 -0
  31. package/dist/agent/run-migrations.js +61 -0
  32. package/dist/automations/service.d.ts +6 -0
  33. package/dist/automations/service.js +35 -0
  34. package/dist/chat-threads/migrations.d.ts +1 -0
  35. package/dist/chat-threads/migrations.js +1 -0
  36. package/dist/chat-threads/schema-migrations.d.ts +8 -0
  37. package/dist/chat-threads/schema-migrations.js +60 -0
  38. package/dist/cli/code-agent-connector.d.ts +4 -1
  39. package/dist/cli/code-agent-connector.js +154 -23
  40. package/dist/cli/code-agent-executor.js +255 -13
  41. package/dist/cli/code-agent-runs.d.ts +1 -0
  42. package/dist/cli/code-agent-runs.js +2 -1
  43. package/dist/cli/portal-workspace.d.ts +49 -0
  44. package/dist/cli/portal-workspace.js +320 -0
  45. package/dist/client/AssistantChat.d.ts +1 -1
  46. package/dist/client/AssistantChat.js +43 -61
  47. package/dist/client/ConnectBuilderCard.js +33 -5
  48. package/dist/client/agent-chat-adapter.js +4 -0
  49. package/dist/client/analytics-platform.d.ts +2 -0
  50. package/dist/client/analytics-platform.js +12 -0
  51. package/dist/client/analytics.d.ts +3 -0
  52. package/dist/client/analytics.js +10 -1
  53. package/dist/client/chat/index.d.ts +1 -0
  54. package/dist/client/chat/index.js +1 -0
  55. package/dist/client/chat/message-components.d.ts +2 -0
  56. package/dist/client/chat/message-components.js +11 -1
  57. package/dist/client/chat/run-recovery.d.ts +2 -1
  58. package/dist/client/chat/run-recovery.js +2 -2
  59. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  60. package/dist/client/chat-first/apps-rail.js +11 -11
  61. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  62. package/dist/client/chat-first/surface-tabs.js +38 -36
  63. package/dist/client/chat-first/types.d.ts +5 -0
  64. package/dist/client/chat-first.d.ts +1 -1
  65. package/dist/client/chat-first.js +4 -4
  66. package/dist/client/desktop-local-code-change.d.ts +10 -0
  67. package/dist/client/desktop-local-code-change.js +16 -0
  68. package/dist/client/index.d.ts +1 -0
  69. package/dist/client/index.js +1 -0
  70. package/dist/client/observability/ThumbsFeedback.js +53 -26
  71. package/dist/client/observability/useObservability.d.ts +1 -1
  72. package/dist/client/observability/useObservability.js +6 -3
  73. package/dist/client/terminal/AgentTerminal.d.ts +9 -1
  74. package/dist/client/terminal/AgentTerminal.js +38 -3
  75. package/dist/client/terminal/index.d.ts +1 -1
  76. package/dist/client/terminal/index.js +1 -1
  77. package/dist/client/track.js +9 -1
  78. package/dist/client/use-action.js +3 -0
  79. package/dist/collab/awareness.d.ts +2 -2
  80. package/dist/deploy/build.js +1 -1
  81. package/dist/feature-flags/a2a-action-route.js +4 -2
  82. package/dist/feature-flags/permissions.d.ts +2 -0
  83. package/dist/feature-flags/permissions.js +6 -1
  84. package/dist/ingestion/pptx.d.ts +92 -1
  85. package/dist/ingestion/pptx.js +1180 -115
  86. package/dist/integrations/index.d.ts +3 -3
  87. package/dist/integrations/index.js +2 -2
  88. package/dist/integrations/plugin.js +95 -6
  89. package/dist/integrations/remote-commands-store.d.ts +5 -0
  90. package/dist/integrations/remote-commands-store.js +63 -22
  91. package/dist/integrations/remote-devices-store.d.ts +2 -1
  92. package/dist/integrations/remote-devices-store.js +46 -0
  93. package/dist/integrations/remote-types.d.ts +20 -0
  94. package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
  95. package/dist/jobs/actions/list-recurring-jobs.js +5 -1
  96. package/dist/jobs/frontmatter.d.ts +16 -0
  97. package/dist/jobs/frontmatter.js +53 -0
  98. package/dist/jobs/remote-execution.d.ts +42 -0
  99. package/dist/jobs/remote-execution.js +287 -0
  100. package/dist/jobs/scheduler.js +102 -3
  101. package/dist/jobs/tools.js +50 -3
  102. package/dist/localization/core-messages/ar-SA.js +4 -0
  103. package/dist/localization/core-messages/de-DE.js +4 -0
  104. package/dist/localization/core-messages/en-US.d.ts +4 -0
  105. package/dist/localization/core-messages/en-US.js +4 -0
  106. package/dist/localization/core-messages/es-ES.js +4 -0
  107. package/dist/localization/core-messages/fr-FR.js +4 -0
  108. package/dist/localization/core-messages/hi-IN.js +4 -0
  109. package/dist/localization/core-messages/ja-JP.js +4 -0
  110. package/dist/localization/core-messages/ko-KR.js +4 -0
  111. package/dist/localization/core-messages/pt-BR.js +4 -0
  112. package/dist/localization/core-messages/zh-CN.js +4 -0
  113. package/dist/localization/core-messages/zh-TW.js +4 -0
  114. package/dist/localization/core-messages.d.ts +4 -0
  115. package/dist/notifications/routes.d.ts +3 -3
  116. package/dist/observability/feedback.js +40 -16
  117. package/dist/observability/routes.d.ts +6 -6
  118. package/dist/observability/routes.js +15 -3
  119. package/dist/observability/store.js +2 -0
  120. package/dist/observability/traces.js +5 -1
  121. package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
  122. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  123. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/secrets/routes.d.ts +9 -9
  126. package/dist/server/action-routes.d.ts +7 -5
  127. package/dist/server/action-routes.js +13 -6
  128. package/dist/server/agent-chat-plugin.js +6 -0
  129. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  130. package/dist/server/agent-run-context.d.ts +1 -0
  131. package/dist/server/agent-run-context.js +9 -0
  132. package/dist/server/better-auth-instance.js +4 -0
  133. package/dist/server/core-routes-plugin.js +8 -1
  134. package/dist/server/realtime-voice.js +2 -1
  135. package/dist/server/release-migrations.js +16 -0
  136. package/dist/server/request-context.d.ts +2 -0
  137. package/dist/server/transcribe-voice.d.ts +1 -1
  138. package/dist/shared/analytics-platform.d.ts +6 -0
  139. package/dist/shared/analytics-platform.js +14 -0
  140. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  141. package/dist/shared/mcp-embed-headers.js +1 -1
  142. package/dist/styles/agent-native.css +83 -103
  143. package/dist/terminal/cli-registry.js +5 -0
  144. package/dist/tracking/registry.js +9 -1
  145. package/dist/triggers/actions/list-automations.d.ts +3 -0
  146. package/dist/triggers/actions/list-automations.js +3 -0
  147. package/dist/triggers/actions/manage-automation.d.ts +9 -0
  148. package/dist/triggers/actions/manage-automation.js +14 -2
  149. package/dist/triggers/actions.js +82 -7
  150. package/dist/usage/migrations.d.ts +4 -0
  151. package/dist/usage/migrations.js +45 -0
  152. package/docs/content/template-content-developers.mdx +48 -0
  153. package/docs/content/toolkit-ui.mdx +57 -0
  154. package/package.json +3 -3
@@ -14,7 +14,10 @@ import { z } from "zod";
14
14
 
15
15
  import { getDb, schema } from "../server/db/index.js";
16
16
  import { notifyClients } from "../server/handlers/decks.js";
17
- import { uploadPptxSlideImages } from "../server/handlers/import/pptx-assets.js";
17
+ import {
18
+ assertPptxImagesRenderable,
19
+ uploadPptxSlideImages,
20
+ } from "../server/handlers/import/pptx-assets.js";
18
21
  import { upsertBuilderProxyDesignSystem } from "../server/lib/builder-design-system-proxy.js";
19
22
  import { setupPdfParse } from "../server/lib/pdf-parse-setup.js";
20
23
  import {
@@ -148,6 +151,7 @@ export default defineAction({
148
151
  await assertAccess("deck", deckId, "editor");
149
152
  const pptxOwnerEmail = getRequestUserEmail();
150
153
  if (!pptxOwnerEmail) throw new Error("no authenticated user");
154
+ assertPptxImagesRenderable(presentation.slides);
151
155
  const pptxThemeFont = presentation.theme?.fonts?.[0];
152
156
  const uploadLimit = pLimit(4);
153
157
  const pptxResults = await Promise.all(
@@ -162,6 +166,10 @@ export default defineAction({
162
166
  (total, r) => total + r.imageSkippedCount,
163
167
  0,
164
168
  );
169
+ const tablesDegraded = presentation.slides.reduce(
170
+ (total, s) => total + (s.tablesDegraded ?? 0),
171
+ 0,
172
+ );
165
173
  const sourceImport = buildSourceImportMetadata({
166
174
  format: "pptx",
167
175
  slides: pptxResults.map((result) => ({
@@ -172,6 +180,7 @@ export default defineAction({
172
180
  editableText: true,
173
181
  })),
174
182
  imagesSkipped,
183
+ tablesDegraded,
175
184
  });
176
185
  if (imagesSkipped > 0) {
177
186
  throw new Error(
@@ -193,6 +202,7 @@ export default defineAction({
193
202
  aspectRatio,
194
203
  sourceImport,
195
204
  pptxResults[0]?.sourceText,
205
+ presentation.theme,
196
206
  );
197
207
  return {
198
208
  format: "pptx",
@@ -202,6 +212,7 @@ export default defineAction({
202
212
  deckId,
203
213
  imported: true,
204
214
  ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
215
+ ...(tablesDegraded > 0 ? { tablesDegraded } : {}),
205
216
  };
206
217
  }
207
218
 
@@ -527,9 +538,14 @@ async function importPdfPagesWithFidelity(args: {
527
538
  ),
528
539
  );
529
540
  const slides = imported.map((entry) => entry.slide);
541
+ const imagesSkipped = fidelityPages.reduce(
542
+ (total, page) => total + page.imagesSkipped,
543
+ 0,
544
+ );
530
545
  const sourceImport = buildSourceImportMetadata({
531
546
  format: "pdf",
532
547
  slides: imported.map((entry) => entry.snapshot),
548
+ imagesSkipped,
533
549
  });
534
550
 
535
551
  const importedTitle = await appendDeckSlides(
@@ -550,6 +566,7 @@ async function importPdfPagesWithFidelity(args: {
550
566
  aspectRatio,
551
567
  deckId,
552
568
  imported: true,
569
+ ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
553
570
  };
554
571
  }
555
572
 
@@ -688,6 +705,7 @@ async function appendDeckSlides(
688
705
  aspectRatio?: AspectRatio,
689
706
  sourceImport?: ReturnType<typeof buildSourceImportMetadata>,
690
707
  titleSource?: unknown,
708
+ theme?: import("../server/handlers/import/pptx-parser.js").ParsedPresentation["theme"],
691
709
  ): Promise<string> {
692
710
  await assertAccess("deck", deckId, "editor");
693
711
 
@@ -736,6 +754,15 @@ async function appendDeckSlides(
736
754
  title: nextTitle,
737
755
  slides: [...previousSlides, ...slides],
738
756
  ...(!hadExistingSlides && aspectRatio ? { aspectRatio } : {}),
757
+ // Same rule as aspectRatio above: an appended file's palette only
758
+ // becomes the deck's theme when the deck had no slides yet, so
759
+ // appending onto an existing deck can't silently restyle every slide
760
+ // already on it. Deliberately keyed on slides rather than on whether a
761
+ // theme already exists: export writes one deck-level theme (OOXML allows
762
+ // one per master), so with zero slides there is nothing to protect, and
763
+ // refusing a new theme there would strand a deck whose slides were
764
+ // deleted with the old file's palette, unfixable by re-importing.
765
+ ...(!hadExistingSlides && theme ? { theme } : {}),
739
766
  ...(nextSourceImport ? { sourceImport: nextSourceImport } : {}),
740
767
  updatedAt: writeNow,
741
768
  };
@@ -12,7 +12,10 @@ import { z } from "zod";
12
12
  import { getDb, schema } from "../server/db/index.js";
13
13
  import { notifyClients } from "../server/handlers/decks.js";
14
14
  import { convertToSlideHtml } from "../server/handlers/import/html-converter.js";
15
- import { uploadPptxSlideImages } from "../server/handlers/import/pptx-assets.js";
15
+ import {
16
+ assertPptxImagesRenderable,
17
+ uploadPptxSlideImages,
18
+ } from "../server/handlers/import/pptx-assets.js";
16
19
  import {
17
20
  parsePptx,
18
21
  type ParsedElement,
@@ -27,6 +30,7 @@ import {
27
30
  } from "../shared/aspect-ratios.js";
28
31
  import { getDeckUrl } from "./_app-url.js";
29
32
  import { readUserUploadedFile } from "./_uploaded-files.js";
33
+ import { withDeckLock } from "./patch-deck.js";
30
34
 
31
35
  export interface ImportedImageFallback {
32
36
  slideIndex: number;
@@ -100,6 +104,7 @@ export async function importPptxBufferToDeck(args: {
100
104
  imported: true;
101
105
  url: string;
102
106
  imagesSkipped?: number;
107
+ tablesDegraded?: number;
103
108
  }> {
104
109
  const {
105
110
  fileBuffer,
@@ -120,9 +125,18 @@ export async function importPptxBufferToDeck(args: {
120
125
  // Check edit access before uploading any embedded images — uploads are
121
126
  // a side effect with real storage cost, so an unauthorized caller must
122
127
  // be rejected before that side effect happens, not after.
128
+ const db = getDb();
123
129
  if (deckId) {
124
130
  await assertAccess("deck", deckId, "editor");
131
+ const [deck] = await db
132
+ .select()
133
+ .from(schema.decks)
134
+ .where(eq(schema.decks.id, deckId));
135
+ if (!deck) {
136
+ throw new Error(`Deck ${deckId} not found`);
137
+ }
125
138
  }
139
+ assertPptxImagesRenderable(presentation.slides);
126
140
 
127
141
  // Convert each parsed slide to its positioned scene graph, uploading every
128
142
  // browser-renderable image so the imported deck keeps the source layering
@@ -156,6 +170,7 @@ export async function importPptxBufferToDeck(args: {
156
170
  sourceText: parsedSlide.texts.map((text) => text.content).join("\n"),
157
171
  imageUrls: Object.values(uploadedImages.urls),
158
172
  imageSkippedCount: uploadedImages.imageSkippedCount,
173
+ tablesDegraded: parsedSlide.tablesDegraded ?? 0,
159
174
  };
160
175
  }),
161
176
  ),
@@ -165,6 +180,10 @@ export async function importPptxBufferToDeck(args: {
165
180
  (total, r) => total + r.imageSkippedCount,
166
181
  0,
167
182
  );
183
+ const tablesDegraded = results.reduce(
184
+ (total, r) => total + r.tablesDegraded,
185
+ 0,
186
+ );
168
187
  if (imagesSkipped > 0) {
169
188
  throw new Error(
170
189
  `Source-faithful PPTX import could not preserve ${imagesSkipped} image(s). No deck was written. Retry with browser-renderable images or use a PDF export for page-faithful preservation.`,
@@ -180,61 +199,67 @@ export async function importPptxBufferToDeck(args: {
180
199
  editableText: true,
181
200
  })),
182
201
  imagesSkipped,
202
+ tablesDegraded,
183
203
  });
184
204
  const aspectRatio = nearestAspectRatio(
185
205
  presentation.slides[0]?.widthEmu,
186
206
  presentation.slides[0]?.heightEmu,
187
207
  );
188
208
 
189
- const db = getDb();
190
209
  const now = new Date().toISOString();
191
210
 
192
211
  if (deckId) {
193
- const existing = await db
194
- .select()
195
- .from(schema.decks)
196
- .where(eq(schema.decks.id, deckId));
197
-
198
- if (!existing.length) {
199
- throw new Error(`Deck ${deckId} not found`);
200
- }
212
+ return withDeckLock(deckId, async () => {
213
+ // Image uploads happen before this lock because they are independent of
214
+ // the deck row. Re-read inside the lock so the replacement is based on
215
+ // the latest deck data rather than the preflight snapshot.
216
+ const [latestDeck] = await db
217
+ .select()
218
+ .from(schema.decks)
219
+ .where(eq(schema.decks.id, deckId));
220
+ if (!latestDeck) {
221
+ throw new Error(`Deck ${deckId} not found`);
222
+ }
201
223
 
202
- const previousData = safeParseDeckData(existing[0].data);
203
- const data = {
204
- ...previousData,
205
- title: deckTitle,
206
- slides,
207
- ...(aspectRatio ? { aspectRatio } : {}),
208
- sourceImport,
209
- updatedAt: now,
210
- };
211
- await db
212
- .update(schema.decks)
213
- .set({
224
+ const previousData = safeParseDeckData(latestDeck.data);
225
+ const data = {
226
+ ...previousData,
214
227
  title: deckTitle,
215
- data: JSON.stringify(data),
216
- ...(designSystemId !== undefined
217
- ? { designSystemId }
218
- : { designSystemId: existing[0].designSystemId }),
228
+ slides,
229
+ ...(aspectRatio ? { aspectRatio } : {}),
230
+ ...(presentation.theme ? { theme: presentation.theme } : {}),
231
+ sourceImport,
219
232
  updatedAt: now,
220
- })
221
- .where(eq(schema.decks.id, deckId));
233
+ };
234
+ await db
235
+ .update(schema.decks)
236
+ .set({
237
+ title: deckTitle,
238
+ data: JSON.stringify(data),
239
+ ...(designSystemId !== undefined
240
+ ? { designSystemId }
241
+ : { designSystemId: latestDeck.designSystemId }),
242
+ updatedAt: now,
243
+ })
244
+ .where(eq(schema.decks.id, deckId));
222
245
 
223
- notifyClients(deckId);
224
- await writeAppState("refresh-signal", {
225
- ts: now,
226
- source,
227
- });
246
+ notifyClients(deckId);
247
+ await writeAppState("refresh-signal", {
248
+ ts: now,
249
+ source,
250
+ });
228
251
 
229
- return {
230
- id: deckId,
231
- title: deckTitle,
232
- slideCount: slides.length,
233
- theme: presentation.theme,
234
- imported: true,
235
- url: getDeckUrl(deckId),
236
- ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
237
- };
252
+ return {
253
+ id: deckId,
254
+ title: deckTitle,
255
+ slideCount: slides.length,
256
+ theme: presentation.theme,
257
+ imported: true,
258
+ url: getDeckUrl(deckId),
259
+ ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
260
+ ...(tablesDegraded > 0 ? { tablesDegraded } : {}),
261
+ };
262
+ });
238
263
  }
239
264
 
240
265
  // Create new deck
@@ -243,6 +268,7 @@ export async function importPptxBufferToDeck(args: {
243
268
  title: deckTitle,
244
269
  slides,
245
270
  ...(aspectRatio ? { aspectRatio } : {}),
271
+ ...(presentation.theme ? { theme: presentation.theme } : {}),
246
272
  sourceImport,
247
273
  createdAt: now,
248
274
  updatedAt: now,
@@ -269,6 +295,7 @@ export async function importPptxBufferToDeck(args: {
269
295
  imported: true,
270
296
  url: getDeckUrl(id),
271
297
  ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
298
+ ...(tablesDegraded > 0 ? { tablesDegraded } : {}),
272
299
  };
273
300
  }
274
301
 
@@ -528,6 +528,11 @@ function useSlideAutofit(
528
528
 
529
529
  root.addEventListener("load", scheduleMeasure, true);
530
530
  document.fonts?.ready.then(scheduleMeasure).catch(() => {});
531
+ // An imported deck's webfonts are requested only once its HTML is on
532
+ // screen, so they can land after `fonts.ready` has already settled. That
533
+ // reflows text without touching the DOM the observers watch, leaving the
534
+ // fit transform measured against the fallback font.
535
+ document.fonts?.addEventListener("loadingdone", scheduleMeasure);
531
536
 
532
537
  // `rootMargin` measures a thumbnail just before it scrolls in, so the fit
533
538
  // transform is already applied by the time it is on screen.
@@ -554,6 +559,7 @@ function useSlideAutofit(
554
559
  mutationObserver.disconnect();
555
560
  visibilityObserver?.disconnect();
556
561
  root.removeEventListener("load", scheduleMeasure, true);
562
+ document.fonts?.removeEventListener("loadingdone", scheduleMeasure);
557
563
  };
558
564
  }, [canvasWidth, canvasHeight, fitKey, ref]);
559
565
  }
@@ -596,6 +602,186 @@ function AutoFitContent({
596
602
  );
597
603
  }
598
604
 
605
+ /**
606
+ * Google Fonts families an imported deck may name. Split by what the `css2`
607
+ * endpoint accepts for each: the first list has a real 100..900 variable
608
+ * weight axis, the rest only serve discrete weights. Asking for an axis a
609
+ * family does not have is a 400 for the whole request, so both lists were
610
+ * checked against the live endpoint rather than guessed from the name.
611
+ */
612
+ const VARIABLE_AXIS_GOOGLE_FONTS = [
613
+ "Archivo",
614
+ "Asap",
615
+ "Catamaran",
616
+ "Chivo",
617
+ "DM Sans",
618
+ "Epilogue",
619
+ "Exo 2",
620
+ "Geist",
621
+ "Heebo",
622
+ "Inter",
623
+ "Jost",
624
+ "League Spartan",
625
+ "Lexend",
626
+ "Libre Franklin",
627
+ "Montserrat",
628
+ "Noto Sans",
629
+ "Noto Serif",
630
+ "Onest",
631
+ "Outfit",
632
+ "Overpass",
633
+ "Public Sans",
634
+ "Raleway",
635
+ "Roboto",
636
+ "Roboto Condensed",
637
+ "Roboto Slab",
638
+ "Urbanist",
639
+ "Work Sans",
640
+ ];
641
+
642
+ const STATIC_WEIGHT_GOOGLE_FONTS = [
643
+ "Abril Fatface",
644
+ "Anton",
645
+ "Arimo",
646
+ "Assistant",
647
+ "Barlow",
648
+ "Barlow Condensed",
649
+ "Bebas Neue",
650
+ "Bodoni Moda",
651
+ "Bricolage Grotesque",
652
+ "Cabin",
653
+ "Caveat",
654
+ "Cormorant Garamond",
655
+ "Cousine",
656
+ "Crimson Text",
657
+ "Dancing Script",
658
+ "David Libre",
659
+ "EB Garamond",
660
+ "Figtree",
661
+ "Fira Sans",
662
+ "Hind",
663
+ "Homemade Apple",
664
+ "IBM Plex Sans",
665
+ "Inconsolata",
666
+ "Instrument Sans",
667
+ "Josefin Sans",
668
+ "Kanit",
669
+ "Karla",
670
+ "Lato",
671
+ "Libre Baskerville",
672
+ "Lora",
673
+ "Manrope",
674
+ "Merriweather",
675
+ "Mulish",
676
+ "Nova Square",
677
+ "Nunito",
678
+ "Nunito Sans",
679
+ "Open Sans",
680
+ "Oswald",
681
+ "Oxygen",
682
+ "PT Sans",
683
+ "PT Serif",
684
+ "Pacifico",
685
+ "Playfair Display",
686
+ "Plus Jakarta Sans",
687
+ "Poppins",
688
+ "Prompt",
689
+ "Quicksand",
690
+ "Red Hat Display",
691
+ "Roboto Mono",
692
+ "Rubik",
693
+ "Schibsted Grotesk",
694
+ "Sora",
695
+ "Source Sans 3",
696
+ "Space Grotesk",
697
+ "Syne",
698
+ "Teko",
699
+ "Tinos",
700
+ "Titillium Web",
701
+ "Ubuntu",
702
+ "Yanone Kaffeesatz",
703
+ ];
704
+
705
+ /** Families decks still name under a name Google Fonts does not serve. */
706
+ const GOOGLE_FONT_ALIASES: Record<string, string> = {
707
+ "source sans pro": "source sans 3",
708
+ bodoni: "bodoni moda",
709
+ };
710
+
711
+ /**
712
+ * PPTX stores a weight as part of the typeface name — a Work Sans deck is full
713
+ * of runs set in `Work Sans Medium` — but a webfont family covers its whole
714
+ * weight range, so the suffixed name matches nothing and falls back to the
715
+ * generic sans that made every imported deck look unstyled.
716
+ */
717
+ const FONT_WEIGHT_SUFFIX =
718
+ /\s+(?:thin|extra ?light|ultra ?light|light|book|regular|normal|medium|semi ?bold|demi ?bold|bold|extra ?bold|ultra ?bold|black|heavy|italic|oblique)$/i;
719
+
720
+ const GOOGLE_FONTS = new Map<string, { family: string; href: string }>();
721
+ for (const [families, axis] of [
722
+ [VARIABLE_AXIS_GOOGLE_FONTS, "ital,wght@0,100..900;1,100..900"],
723
+ [STATIC_WEIGHT_GOOGLE_FONTS, "ital,wght@0,400;0,700;1,400;1,700"],
724
+ ] as const) {
725
+ for (const family of families) {
726
+ GOOGLE_FONTS.set(family.toLowerCase(), {
727
+ family,
728
+ href: `https://fonts.googleapis.com/css2?family=${family.replace(/ /g, "+")}:${axis}&display=swap`,
729
+ });
730
+ }
731
+ }
732
+
733
+ /** The webfont that should render `name`, or undefined when none can. */
734
+ export function resolveImportedFont(
735
+ name: string,
736
+ ): { family: string; href: string } | undefined {
737
+ const key = name
738
+ .replace(/["']/g, "")
739
+ .trim()
740
+ .replace(/\s+/g, " ")
741
+ .toLowerCase();
742
+ if (!key) return undefined;
743
+ const lookup = (candidate: string) =>
744
+ GOOGLE_FONTS.get(GOOGLE_FONT_ALIASES[candidate] ?? candidate);
745
+ return lookup(key) ?? lookup(key.replace(FONT_WEIGHT_SUFFIX, "").trim());
746
+ }
747
+
748
+ /**
749
+ * Point every quoted `font-family` in imported slide HTML at a family we can
750
+ * actually serve, and collect the stylesheets that serve them. Families we
751
+ * cannot serve are left exactly as authored so the browser can still match a
752
+ * locally installed copy.
753
+ */
754
+ export function prepareImportedFonts(html: string): {
755
+ html: string;
756
+ hrefs: string[];
757
+ } {
758
+ const hrefs = new Set<string>();
759
+ const rewritten = html.replace(
760
+ /(font-family:\s*)'([^']*)'/gi,
761
+ (match, prefix: string, name: string) => {
762
+ const font = resolveImportedFont(name);
763
+ if (!font) return match;
764
+ hrefs.add(font.href);
765
+ return `${prefix}'${font.family}'`;
766
+ },
767
+ );
768
+ return { html: rewritten, hrefs: [...hrefs] };
769
+ }
770
+
771
+ const injectedFontHrefs = new Set<string>();
772
+
773
+ function loadImportedFonts(hrefs: string[]) {
774
+ if (typeof document === "undefined") return;
775
+ for (const href of hrefs) {
776
+ if (injectedFontHrefs.has(href)) continue;
777
+ injectedFontHrefs.add(href);
778
+ const link = document.createElement("link");
779
+ link.rel = "stylesheet";
780
+ link.href = href;
781
+ document.head.appendChild(link);
782
+ }
783
+ }
784
+
599
785
  /** Renders blank slide HTML content and applies white filter to logo images */
600
786
  function BlankSlideContent({ content }: { content: string }) {
601
787
  const scopeId = `slide-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
@@ -607,36 +793,43 @@ function BlankSlideContent({ content }: { content: string }) {
607
793
  // literal each render therefore wipes any DOM mutations made on children. That
608
794
  // includes the per-block `contentEditable="true"` set by SlideEditor's
609
795
  // double-click-to-edit flow, which made inline text editing appear to do nothing.
610
- const { mermaidBlocks, htmlWithPlaceholders, dangerousHtml } = useMemo(() => {
611
- // Extract mermaid blocks BEFORE sanitization — see mermaid-blocks.ts for
612
- // why (sanitizer HTML-escaping breaks the mermaid parser).
613
- const { blocks, contentWithPlaceholders } = extractMermaidBlocks(content);
614
-
615
- // Apply white filter to all logo images (brandfetch, logo.dev, etc.) for dark backgrounds
616
- const processed = sanitizeSlideHtml(
617
- contentWithPlaceholders.replace(
618
- /(<img\s+(?=[^>]*src="[^"]*(?:brandfetch|logo\.dev)[^"]*")[^>]*)(\/?>)/gi,
619
- (_match, before, close) => {
620
- if (before.includes('style="')) {
621
- return (
622
- before.replace(
623
- 'style="',
624
- 'style="filter:brightness(0) invert(1);',
625
- ) + close
626
- );
627
- }
628
- return before + ' style="filter:brightness(0) invert(1);"' + close;
629
- },
630
- ),
631
- { scopeSelector },
632
- );
796
+ const { mermaidBlocks, htmlWithPlaceholders, dangerousHtml, fontHrefs } =
797
+ useMemo(() => {
798
+ // Extract mermaid blocks BEFORE sanitization — see mermaid-blocks.ts for
799
+ // why (sanitizer HTML-escaping breaks the mermaid parser).
800
+ const { blocks, contentWithPlaceholders } = extractMermaidBlocks(content);
801
+
802
+ // Apply white filter to all logo images (brandfetch, logo.dev, etc.) for dark backgrounds
803
+ const sanitized = sanitizeSlideHtml(
804
+ contentWithPlaceholders.replace(
805
+ /(<img\s+(?=[^>]*src="[^"]*(?:brandfetch|logo\.dev)[^"]*")[^>]*)(\/?>)/gi,
806
+ (_match, before, close) => {
807
+ if (before.includes('style="')) {
808
+ return (
809
+ before.replace(
810
+ 'style="',
811
+ 'style="filter:brightness(0) invert(1);',
812
+ ) + close
813
+ );
814
+ }
815
+ return before + ' style="filter:brightness(0) invert(1);"' + close;
816
+ },
817
+ ),
818
+ { scopeSelector },
819
+ );
820
+ const { html: processed, hrefs } = prepareImportedFonts(sanitized);
633
821
 
634
- return {
635
- mermaidBlocks: blocks,
636
- htmlWithPlaceholders: processed,
637
- dangerousHtml: { __html: processed },
638
- };
639
- }, [content]);
822
+ return {
823
+ mermaidBlocks: blocks,
824
+ htmlWithPlaceholders: processed,
825
+ dangerousHtml: { __html: processed },
826
+ fontHrefs: hrefs,
827
+ };
828
+ }, [content]);
829
+
830
+ useEffect(() => {
831
+ loadImportedFonts(fontHrefs);
832
+ }, [fontHrefs]);
640
833
 
641
834
  if (mermaidBlocks.length > 0) {
642
835
  return (
@@ -35,12 +35,52 @@ import {
35
35
  DropdownMenuSubContent,
36
36
  DropdownMenuSubTrigger,
37
37
  } from "@/components/ui/dropdown-menu";
38
+ import { useDecks } from "@/context/DeckContext";
38
39
  import type { GoogleSlidesExportResult } from "@/lib/export-google-slides-client";
39
40
 
40
41
  /** Google Slides' File → Import dialog, primed to ask for a file. */
41
42
  const GOOGLE_SLIDES_IMPORT_URL =
42
43
  "https://docs.google.com/presentation/u/0/?usp=import";
43
44
 
45
+ /**
46
+ * The importer stamps this on every slide it writes, and `parseSlideHtml` in
47
+ * actions/export-pptx.ts branches on the same marker: those slides carry the
48
+ * source file's own geometry, which the server emits as real `custGeom` vector
49
+ * shapes. dom-to-pptx has no custGeom at all and rasterizes them to PNGs.
50
+ */
51
+ const IMPORTED_SLIDE_MARKER = 'data-imported-pptx="true"';
52
+
53
+ /**
54
+ * Objects whose geometry only exists once a browser has laid the slide out:
55
+ * `freezeSlideElementForFreeform` and the text-box tool mint
56
+ * `data-slide-object-id` client-side, while every object the importer emits
57
+ * also carries `data-pptx-element-kind`. The server has no layout engine to
58
+ * measure the former, so it refuses those slides rather than reflowing them.
59
+ */
60
+ const BROWSER_AUTHORED_OBJECT =
61
+ "[data-slide-object-id]:not([data-pptx-element-kind]), .fmd-freeform-object";
62
+
63
+ /**
64
+ * Whether the vector-capable server exporter can render this deck losslessly.
65
+ * `get-deck` returns the import receipt alongside the deck body, so the client
66
+ * deck carries `sourceImport` at runtime even though the type predates it.
67
+ */
68
+ export function canExportPptxFromServer(
69
+ deck:
70
+ | { sourceImport?: unknown; slides: { content?: string }[] }
71
+ | null
72
+ | undefined,
73
+ ): boolean {
74
+ if (!deck?.sourceImport || deck.slides.length === 0) return false;
75
+ return deck.slides.every((slide) => {
76
+ const html = slide.content ?? "";
77
+ if (!html.includes(IMPORTED_SLIDE_MARKER)) return false;
78
+ return !new DOMParser()
79
+ .parseFromString(html, "text/html")
80
+ .querySelector(BROWSER_AUTHORED_OBJECT);
81
+ });
82
+ }
83
+
44
84
  interface ExportMenuProps {
45
85
  deckId: string;
46
86
  deckTitle: string;
@@ -76,6 +116,7 @@ export const ExportMenu = forwardRef<ExportMenuHandle, ExportMenuProps>(
76
116
  ref,
77
117
  ) {
78
118
  const t = useT();
119
+ const { getDeck, flushDeckSave } = useDecks();
79
120
  const [googleSlidesImportOpen, setGoogleSlidesImportOpen] = useState(false);
80
121
  const googleSlidesImportTarget = useRef<Window | null>(null);
81
122
  const triggerBlobDownload = (blob: Blob, filename: string) => {
@@ -108,8 +149,38 @@ export const ExportMenu = forwardRef<ExportMenuHandle, ExportMenuProps>(
108
149
  }
109
150
  };
110
151
 
152
+ const exportPptxFromServer = async () => {
153
+ // The server exports the persisted deck, so an unflushed edit would be
154
+ // missing from the file the user just asked for.
155
+ await flushDeckSave(deckId);
156
+ const res = await fetch(`${appBasePath()}/api/exports/pptx`, {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json" },
159
+ body: JSON.stringify({ deckId }),
160
+ });
161
+ if (!res.ok) {
162
+ throw new Error(
163
+ await readErrorMessage(res, t("editorExport.exportPptxError")),
164
+ );
165
+ }
166
+ triggerBlobDownload(
167
+ await res.blob(),
168
+ filenameFromDisposition(
169
+ res.headers.get("content-disposition"),
170
+ ".pptx",
171
+ ),
172
+ );
173
+ };
174
+
111
175
  const handleExportPptx = async () => {
112
176
  try {
177
+ // An imported deck's shapes survive only on the server path. Falling
178
+ // back to the browser exporter on failure would hand back rasterized
179
+ // silhouettes of the same deck without saying so.
180
+ if (canExportPptxFromServer(getDeck(deckId))) {
181
+ await exportPptxFromServer();
182
+ return;
183
+ }
113
184
  await onExportPptx();
114
185
  } catch (err) {
115
186
  console.error("Export failed:", err);