@agent-native/core 0.157.9 → 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 (107) hide show
  1. package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +5 -2
  2. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
  3. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +18 -6
  4. package/corpus/templates/analytics/server/plugins/db.ts +139 -0
  5. package/corpus/templates/slides/AGENTS.md +14 -10
  6. package/corpus/templates/slides/actions/export-pptx.ts +1094 -94
  7. package/corpus/templates/slides/actions/import-file.ts +16 -1
  8. package/corpus/templates/slides/actions/import-pptx.ts +61 -43
  9. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
  10. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
  11. package/corpus/templates/slides/app/global.css +19 -1
  12. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
  13. package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
  14. package/corpus/templates/slides/app/lib/export-pptx-client.ts +556 -25
  15. package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
  16. package/corpus/templates/slides/server/handlers/import/html-converter.ts +1176 -26
  17. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +13 -0
  18. package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
  19. package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
  20. package/dist/agent/harness/migrations.js +24 -3
  21. package/dist/agent/production-agent.js +11 -0
  22. package/dist/agent/run-loop-with-resume.js +21 -4
  23. package/dist/automations/service.d.ts +6 -0
  24. package/dist/automations/service.js +35 -0
  25. package/dist/cli/code-agent-connector.d.ts +4 -1
  26. package/dist/cli/code-agent-connector.js +154 -23
  27. package/dist/cli/code-agent-executor.js +255 -13
  28. package/dist/cli/code-agent-runs.d.ts +1 -0
  29. package/dist/cli/code-agent-runs.js +2 -1
  30. package/dist/cli/portal-workspace.d.ts +49 -0
  31. package/dist/cli/portal-workspace.js +320 -0
  32. package/dist/client/AssistantChat.d.ts +1 -1
  33. package/dist/client/AssistantChat.js +39 -61
  34. package/dist/client/ConnectBuilderCard.js +33 -5
  35. package/dist/client/agent-chat-adapter.js +4 -0
  36. package/dist/client/analytics-platform.d.ts +2 -0
  37. package/dist/client/analytics-platform.js +12 -0
  38. package/dist/client/analytics.d.ts +3 -0
  39. package/dist/client/analytics.js +10 -1
  40. package/dist/client/chat/index.d.ts +1 -0
  41. package/dist/client/chat/index.js +1 -0
  42. package/dist/client/chat/run-recovery.d.ts +2 -1
  43. package/dist/client/chat/run-recovery.js +2 -2
  44. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  45. package/dist/client/chat-first/apps-rail.js +11 -11
  46. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  47. package/dist/client/chat-first/surface-tabs.js +38 -36
  48. package/dist/client/chat-first/types.d.ts +5 -0
  49. package/dist/client/chat-first.d.ts +1 -1
  50. package/dist/client/chat-first.js +4 -4
  51. package/dist/client/desktop-local-code-change.d.ts +10 -0
  52. package/dist/client/desktop-local-code-change.js +16 -0
  53. package/dist/client/index.d.ts +1 -0
  54. package/dist/client/index.js +1 -0
  55. package/dist/client/observability/ThumbsFeedback.js +21 -7
  56. package/dist/client/terminal/AgentTerminal.d.ts +9 -1
  57. package/dist/client/terminal/AgentTerminal.js +38 -3
  58. package/dist/client/terminal/index.d.ts +1 -1
  59. package/dist/client/terminal/index.js +1 -1
  60. package/dist/client/track.js +9 -1
  61. package/dist/client/use-action.js +3 -0
  62. package/dist/collab/struct-routes.d.ts +1 -1
  63. package/dist/deploy/build.js +1 -1
  64. package/dist/ingestion/pptx.d.ts +78 -0
  65. package/dist/ingestion/pptx.js +991 -107
  66. package/dist/integrations/index.d.ts +3 -3
  67. package/dist/integrations/index.js +2 -2
  68. package/dist/integrations/plugin.js +95 -6
  69. package/dist/integrations/remote-commands-store.d.ts +5 -0
  70. package/dist/integrations/remote-commands-store.js +63 -22
  71. package/dist/integrations/remote-devices-store.d.ts +2 -1
  72. package/dist/integrations/remote-devices-store.js +46 -0
  73. package/dist/integrations/remote-types.d.ts +20 -0
  74. package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
  75. package/dist/jobs/actions/list-recurring-jobs.js +5 -1
  76. package/dist/jobs/frontmatter.d.ts +16 -0
  77. package/dist/jobs/frontmatter.js +53 -0
  78. package/dist/jobs/remote-execution.d.ts +42 -0
  79. package/dist/jobs/remote-execution.js +287 -0
  80. package/dist/jobs/scheduler.js +102 -3
  81. package/dist/jobs/tools.js +50 -3
  82. package/dist/notifications/routes.d.ts +4 -4
  83. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  84. package/dist/provider-api/actions/provider-api.d.ts +14 -14
  85. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  86. package/dist/resources/handlers.d.ts +1 -1
  87. package/dist/server/action-routes.js +4 -2
  88. package/dist/server/agent-chat-plugin.js +6 -0
  89. package/dist/server/agent-run-context.d.ts +1 -0
  90. package/dist/server/agent-run-context.js +9 -0
  91. package/dist/server/core-routes-plugin.js +8 -1
  92. package/dist/server/request-context.d.ts +2 -0
  93. package/dist/shared/analytics-platform.d.ts +6 -0
  94. package/dist/shared/analytics-platform.js +14 -0
  95. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  96. package/dist/shared/mcp-embed-headers.js +1 -1
  97. package/dist/styles/agent-native.css +83 -103
  98. package/dist/terminal/cli-registry.js +5 -0
  99. package/dist/tracking/registry.js +9 -1
  100. package/dist/triggers/actions/list-automations.d.ts +3 -0
  101. package/dist/triggers/actions/list-automations.js +3 -0
  102. package/dist/triggers/actions/manage-automation.d.ts +9 -0
  103. package/dist/triggers/actions/manage-automation.js +14 -2
  104. package/dist/triggers/actions.js +82 -7
  105. package/docs/content/template-content-developers.mdx +48 -0
  106. package/docs/content/toolkit-ui.mdx +57 -0
  107. package/package.json +2 -2
@@ -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(
@@ -198,6 +202,7 @@ export default defineAction({
198
202
  aspectRatio,
199
203
  sourceImport,
200
204
  pptxResults[0]?.sourceText,
205
+ presentation.theme,
201
206
  );
202
207
  return {
203
208
  format: "pptx",
@@ -700,6 +705,7 @@ async function appendDeckSlides(
700
705
  aspectRatio?: AspectRatio,
701
706
  sourceImport?: ReturnType<typeof buildSourceImportMetadata>,
702
707
  titleSource?: unknown,
708
+ theme?: import("../server/handlers/import/pptx-parser.js").ParsedPresentation["theme"],
703
709
  ): Promise<string> {
704
710
  await assertAccess("deck", deckId, "editor");
705
711
 
@@ -748,6 +754,15 @@ async function appendDeckSlides(
748
754
  title: nextTitle,
749
755
  slides: [...previousSlides, ...slides],
750
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 } : {}),
751
766
  ...(nextSourceImport ? { sourceImport: nextSourceImport } : {}),
752
767
  updatedAt: writeNow,
753
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;
@@ -121,9 +125,18 @@ export async function importPptxBufferToDeck(args: {
121
125
  // Check edit access before uploading any embedded images — uploads are
122
126
  // a side effect with real storage cost, so an unauthorized caller must
123
127
  // be rejected before that side effect happens, not after.
128
+ const db = getDb();
124
129
  if (deckId) {
125
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
+ }
126
138
  }
139
+ assertPptxImagesRenderable(presentation.slides);
127
140
 
128
141
  // Convert each parsed slide to its positioned scene graph, uploading every
129
142
  // browser-renderable image so the imported deck keeps the source layering
@@ -193,56 +206,60 @@ export async function importPptxBufferToDeck(args: {
193
206
  presentation.slides[0]?.heightEmu,
194
207
  );
195
208
 
196
- const db = getDb();
197
209
  const now = new Date().toISOString();
198
210
 
199
211
  if (deckId) {
200
- const existing = await db
201
- .select()
202
- .from(schema.decks)
203
- .where(eq(schema.decks.id, deckId));
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
+ }
204
223
 
205
- if (!existing.length) {
206
- throw new Error(`Deck ${deckId} not found`);
207
- }
208
-
209
- const previousData = safeParseDeckData(existing[0].data);
210
- const data = {
211
- ...previousData,
212
- title: deckTitle,
213
- slides,
214
- ...(aspectRatio ? { aspectRatio } : {}),
215
- sourceImport,
216
- updatedAt: now,
217
- };
218
- await db
219
- .update(schema.decks)
220
- .set({
224
+ const previousData = safeParseDeckData(latestDeck.data);
225
+ const data = {
226
+ ...previousData,
221
227
  title: deckTitle,
222
- data: JSON.stringify(data),
223
- ...(designSystemId !== undefined
224
- ? { designSystemId }
225
- : { designSystemId: existing[0].designSystemId }),
228
+ slides,
229
+ ...(aspectRatio ? { aspectRatio } : {}),
230
+ ...(presentation.theme ? { theme: presentation.theme } : {}),
231
+ sourceImport,
226
232
  updatedAt: now,
227
- })
228
- .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));
229
245
 
230
- notifyClients(deckId);
231
- await writeAppState("refresh-signal", {
232
- ts: now,
233
- source,
234
- });
246
+ notifyClients(deckId);
247
+ await writeAppState("refresh-signal", {
248
+ ts: now,
249
+ source,
250
+ });
235
251
 
236
- return {
237
- id: deckId,
238
- title: deckTitle,
239
- slideCount: slides.length,
240
- theme: presentation.theme,
241
- imported: true,
242
- url: getDeckUrl(deckId),
243
- ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
244
- ...(tablesDegraded > 0 ? { tablesDegraded } : {}),
245
- };
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
+ });
246
263
  }
247
264
 
248
265
  // Create new deck
@@ -251,6 +268,7 @@ export async function importPptxBufferToDeck(args: {
251
268
  title: deckTitle,
252
269
  slides,
253
270
  ...(aspectRatio ? { aspectRatio } : {}),
271
+ ...(presentation.theme ? { theme: presentation.theme } : {}),
254
272
  sourceImport,
255
273
  createdAt: now,
256
274
  updatedAt: now,
@@ -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);
@@ -537,9 +537,21 @@ button[title="Open agent sidebar"] {
537
537
  }
538
538
 
539
539
  .fmd-autofit-scale {
540
+ transform-origin: top left;
541
+ }
542
+
543
+ /* Only apply the fit transform once autofit is actually shrinking content
544
+ (see SlideRenderer's `data-fmd-autofit-active`). Any non-"none" `transform`
545
+ value — even the identity transform this rule used to apply unconditionally
546
+ — creates a new containing block for `position: absolute` descendants
547
+ (CSS Transforms spec). Freeform slide objects are authored against the
548
+ full `.fmd-slide` box; an always-on transform here silently re-anchored
549
+ them to this layer's smaller, padding-inset box instead, shifting them
550
+ off-canvas. Scoping the transform to the active-fit state keeps the
551
+ common (no scaling needed) case free of a containing block at all. */
552
+ .fmd-autofit-scale[data-fmd-autofit-active] {
540
553
  transform: translate(var(--fmd-fit-x, 0px), var(--fmd-fit-y, 0px))
541
554
  scale(var(--fmd-fit-scale, 1));
542
- transform-origin: top left;
543
555
  }
544
556
 
545
557
  .fmd-slide > [data-fmd-autofit-content] {
@@ -1084,8 +1096,14 @@ button[title="Open agent sidebar"] {
1084
1096
  font-style: normal;
1085
1097
  }
1086
1098
 
1099
+ /* An imported PPTX `a:srcRect` crop is rendered by oversizing the `<img>` past
1100
+ its `overflow: hidden` box (e.g. `width: 255%; left: -77%`), so both caps
1101
+ have to be lifted, not just the vertical one — `max-width: 100%` alone
1102
+ silently shrank the image back to its box while the negative `left` kept
1103
+ scrolling, leaving the visible crop window off the picture entirely. */
1087
1104
  .slide-content .fmd-slide img {
1088
1105
  border-radius: 0;
1106
+ max-width: none;
1089
1107
  max-height: none;
1090
1108
  margin: 0;
1091
1109
  }
@@ -15,6 +15,40 @@ export type GoogleSlidesExportResult =
15
15
  /** The export action should send the user through Google OAuth first. */
16
16
  | { url: null; requiresConnection: true; reason: string };
17
17
 
18
+ export interface DeckPptxFile {
19
+ blob: Blob;
20
+ filename: string;
21
+ }
22
+
23
+ /**
24
+ * Renders the deck through the vector-capable server exporter — the only path
25
+ * that emits the source file's shapes as real `custGeom` geometry. Its
26
+ * positioned-object guard is rethrown verbatim so the caller can show it
27
+ * instead of quietly handing Google the rasterized browser export.
28
+ */
29
+ export async function fetchDeckPptxFromServer(
30
+ deckId: string,
31
+ fallbackError: string,
32
+ ): Promise<DeckPptxFile> {
33
+ const res = await fetch(`${appBasePath()}/api/exports/pptx`, {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify({ deckId }),
37
+ });
38
+ if (!res.ok) {
39
+ const payload = (await res.json().catch(() => null)) as {
40
+ error?: string;
41
+ message?: string;
42
+ } | null;
43
+ throw new Error(payload?.error || payload?.message || fallbackError);
44
+ }
45
+ const disposition = res.headers.get("content-disposition");
46
+ return {
47
+ blob: await res.blob(),
48
+ filename: disposition?.match(/filename="?([^"]+)"?/i)?.[1] ?? "deck.pptx",
49
+ };
50
+ }
51
+
18
52
  function triggerBlobDownload(blob: Blob, filename: string) {
19
53
  const url = URL.createObjectURL(blob);
20
54
  const a = document.createElement("a");
@@ -37,12 +71,17 @@ export async function exportDeckToGoogleSlides(
37
71
  deckTitle: string,
38
72
  slides: GoogleSlidesExportSlide[],
39
73
  aspectRatio?: AspectRatio,
74
+ /**
75
+ * Overrides the browser exporter. Source-imported decks pass
76
+ * `fetchDeckPptxFromServer`: dom-to-pptx ships no custGeom support, so the
77
+ * browser build would upload PNG rasterizations of the very shapes Google
78
+ * Slides can otherwise keep editable.
79
+ */
80
+ buildPptx?: () => Promise<DeckPptxFile>,
40
81
  ): Promise<GoogleSlidesExportResult> {
41
- const { blob, filename } = await buildDeckPptxBlob(
42
- deckTitle,
43
- slides,
44
- aspectRatio,
45
- );
82
+ const { blob, filename } = await (buildPptx
83
+ ? buildPptx()
84
+ : buildDeckPptxBlob(deckTitle, slides, aspectRatio));
46
85
 
47
86
  const form = new FormData();
48
87
  form.append("file", blob, filename);