@agent-native/core 0.119.0 → 0.119.1

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 (64) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/ingestion/figma-node-to-html.ts +20 -99
  5. package/corpus/core/src/ingestion/figma-paint-math.ts +371 -0
  6. package/corpus/core/src/ingestion/index.ts +20 -0
  7. package/corpus/templates/assets/app/routes/library.tsx +17 -9
  8. package/corpus/templates/design/AGENTS.md +18 -5
  9. package/corpus/templates/design/FIGMA_INTEROPERABILITY.md +25 -12
  10. package/corpus/templates/design/actions/hydrate-figma-paste-images.ts +115 -0
  11. package/corpus/templates/design/actions/import-figma-clipboard.ts +83 -18
  12. package/corpus/templates/design/actions/import-figma-frame.ts +34 -21
  13. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +28 -1
  14. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +106 -11
  15. package/corpus/templates/design/app/components/design/FigmaHydrationDialog.tsx +273 -0
  16. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +7 -7
  17. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +57 -9
  18. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +10 -0
  19. package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +8 -1
  20. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
  21. package/corpus/templates/design/app/i18n-data.ts +150 -2
  22. package/corpus/templates/design/app/lib/design-file-upload.ts +85 -0
  23. package/corpus/templates/design/app/lib/design-import.ts +19 -1
  24. package/corpus/templates/design/app/lib/figma-clipboard.ts +110 -10
  25. package/corpus/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md +6 -0
  26. package/corpus/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md +5 -0
  27. package/corpus/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md +6 -0
  28. package/corpus/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md +6 -0
  29. package/corpus/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md +6 -0
  30. package/corpus/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md +6 -0
  31. package/corpus/templates/design/server/handlers/import-design-file.ts +50 -0
  32. package/corpus/templates/design/server/lib/fig-file-decoder.ts +90 -49
  33. package/corpus/templates/design/server/lib/fig-file-import.ts +19 -5
  34. package/corpus/templates/design/server/lib/fig-file-to-html.ts +583 -78
  35. package/corpus/templates/design/server/lib/figma-clipboard-local-decode.ts +238 -0
  36. package/corpus/templates/design/server/lib/figma-image-hydration.ts +389 -0
  37. package/corpus/templates/design/server/lib/figma-node-import.ts +136 -36
  38. package/corpus/templates/design/server/register-secrets.ts +14 -2
  39. package/dist/collab/awareness.d.ts +2 -2
  40. package/dist/collab/awareness.d.ts.map +1 -1
  41. package/dist/collab/struct-routes.d.ts +1 -1
  42. package/dist/ingestion/figma-node-to-html.d.ts.map +1 -1
  43. package/dist/ingestion/figma-node-to-html.js +14 -88
  44. package/dist/ingestion/figma-node-to-html.js.map +1 -1
  45. package/dist/ingestion/figma-paint-math.d.ts +157 -0
  46. package/dist/ingestion/figma-paint-math.d.ts.map +1 -0
  47. package/dist/ingestion/figma-paint-math.js +274 -0
  48. package/dist/ingestion/figma-paint-math.js.map +1 -0
  49. package/dist/ingestion/index.d.ts +1 -0
  50. package/dist/ingestion/index.d.ts.map +1 -1
  51. package/dist/ingestion/index.js +1 -0
  52. package/dist/ingestion/index.js.map +1 -1
  53. package/dist/observability/routes.d.ts +5 -5
  54. package/dist/progress/routes.d.ts +1 -1
  55. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  56. package/dist/provider-api/actions/provider-api.d.ts +7 -7
  57. package/dist/resources/handlers.d.ts +1 -1
  58. package/dist/secrets/routes.d.ts +3 -3
  59. package/dist/server/realtime-token.d.ts +1 -1
  60. package/dist/server/transcribe-voice.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/src/ingestion/figma-node-to-html.ts +20 -99
  63. package/src/ingestion/figma-paint-math.ts +371 -0
  64. package/src/ingestion/index.ts +20 -0
@@ -27,6 +27,7 @@ import {
27
27
  } from "@/lib/design-file-upload";
28
28
  import {
29
29
  importResultNotification,
30
+ isFigmaRateLimitImportError,
30
31
  looksLikeStandaloneHtml,
31
32
  VISUAL_EDIT_CONNECT_COMMAND,
32
33
  VISUAL_EDIT_INSTALL_COMMAND,
@@ -42,6 +43,7 @@ import type { DesignExtensionSlotContext } from "./DesignExtensionsPanel";
42
43
 
43
44
  interface DesignImportPanelProps {
44
45
  context: Pick<DesignExtensionSlotContext, "designId" | "viewMode">;
46
+ onImport?: (result: ImportResult) => void;
45
47
  }
46
48
 
47
49
  type ImportMode =
@@ -51,7 +53,11 @@ type ImportMode =
51
53
  | "html"
52
54
  | "local-app";
53
55
 
54
- export function DesignImportPanel({ context }: DesignImportPanelProps) {
56
+ export function DesignImportPanel(p: DesignImportPanelProps) {
57
+ const context = p.context;
58
+ const onImport = p.onImport;
59
+ const onImportRef = useRef(onImport);
60
+ onImportRef.current = onImport;
55
61
  const t = useT();
56
62
  const { formatNumber } = useFormatters();
57
63
  const navigate = useNavigate();
@@ -77,6 +83,8 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
77
83
  const [htmlText, setHtmlText] = useState("");
78
84
  const [activeMode, setActiveMode] = useState<ImportMode | null>(null);
79
85
  const [lastResult, setLastResult] = useState<ImportResult | null>(null);
86
+ const [figmaRateLimitError, setFigmaRateLimitError] =
87
+ useState<ImportResult | null>(null);
80
88
  const [figUploadName, setFigUploadName] = useState<string | null>(null);
81
89
  const [figUploadProgress, setFigUploadProgress] = useState<number | null>(
82
90
  null,
@@ -91,6 +99,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
91
99
  queryClient.invalidateQueries({ queryKey: ["action", "get-design"] }),
92
100
  queryClient.invalidateQueries({ queryKey: ["action"] }),
93
101
  ]);
102
+ if (result) onImportRef.current?.(result);
94
103
  const fidelityWarnings: string[] = [];
95
104
  const imageFallbackCount = result?.fidelityReport?.imageFallbacks.length;
96
105
  if (imageFallbackCount) {
@@ -190,6 +199,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
190
199
  figmaConnectionChecked && !figmaConnected && !figmaConnectionError;
191
200
 
192
201
  const handleFigmaUrlImport = useCallback(async () => {
202
+ setFigmaRateLimitError(null);
193
203
  const normalizedUrl = figmaUrl.trim();
194
204
  if (!normalizedUrl) {
195
205
  toast.error(t("designEditor.import.errors.figmaUrlRequired"));
@@ -218,9 +228,41 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
218
228
  asNewScreen: true,
219
229
  })) as ImportResult;
220
230
  await finishImport(result, t("designEditor.import.figmaUrlSuccess"));
231
+ setFigmaRateLimitError(null);
221
232
  } catch (error) {
222
233
  // A rejected credential should not linger in component state or the DOM.
223
234
  setFigmaAccessToken("");
235
+ const rateLimitDetails = error as Error & {
236
+ rateLimitRetryAfter?: number;
237
+ rateLimitPlanTier?: string;
238
+ figmaPlanTier?: string;
239
+ rateLimitType?: string;
240
+ figmaRateLimitType?: string;
241
+ rateLimitUpgradeUrl?: string;
242
+ figmaUpgradeUrl?: string;
243
+ };
244
+ const rateLimitResult: ImportResult = {
245
+ error:
246
+ typeof rateLimitDetails.message === "string"
247
+ ? rateLimitDetails.message
248
+ : t("common.genericError"),
249
+ rateLimitRetryAfter: rateLimitDetails.rateLimitRetryAfter,
250
+ rateLimitPlanTier:
251
+ rateLimitDetails.rateLimitPlanTier ?? rateLimitDetails.figmaPlanTier,
252
+ rateLimitType:
253
+ rateLimitDetails.rateLimitType ?? rateLimitDetails.figmaRateLimitType,
254
+ rateLimitUpgradeUrl:
255
+ rateLimitDetails.rateLimitUpgradeUrl ??
256
+ rateLimitDetails.figmaUpgradeUrl,
257
+ };
258
+ const isRateLimitError =
259
+ (error instanceof Error &&
260
+ /rate limit|429|quota/i.test(error.message)) ||
261
+ isFigmaRateLimitImportError(rateLimitResult);
262
+ if (isRateLimitError) {
263
+ setFigmaRateLimitError(rateLimitResult);
264
+ setActiveMode("fig-upload");
265
+ }
224
266
  toast.error(t("designEditor.import.errors.figmaImportFailed"), {
225
267
  description:
226
268
  error instanceof Error ? error.message : t("common.genericError"),
@@ -282,6 +324,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
282
324
  onProgress: ({ percent }) => setFigUploadProgress(percent),
283
325
  });
284
326
  await finishImport(result, t("designEditor.import.uploadSuccess"));
327
+ setFigmaRateLimitError(null);
285
328
  } catch (error) {
286
329
  toast.error(t("designEditor.import.errors.uploadFailed"), {
287
330
  description:
@@ -325,6 +368,64 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
325
368
 
326
369
  <div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-4 pt-3">
327
370
  <div className="space-y-0.5">
371
+ {figmaRateLimitError ? (
372
+ <div className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-[11px] leading-snug">
373
+ <p className="font-medium text-destructive">
374
+ {t("designEditor.import.rateLimitTitle")}
375
+ </p>
376
+ <p className="text-muted-foreground">
377
+ {figmaRateLimitError.rateLimitType === "low"
378
+ ? t("designEditor.import.rateLimitLowSeat")
379
+ : t("designEditor.import.rateLimitGeneric")}
380
+ </p>
381
+ {figmaRateLimitError.rateLimitRetryAfter ? (
382
+ <p className="text-muted-foreground">
383
+ {t("designEditor.import.rateLimitRetryIn", {
384
+ time:
385
+ figmaRateLimitError.rateLimitRetryAfter >= 60
386
+ ? `${Math.ceil(figmaRateLimitError.rateLimitRetryAfter / 60)} min`
387
+ : `${figmaRateLimitError.rateLimitRetryAfter}s`,
388
+ })}
389
+ </p>
390
+ ) : null}
391
+ <div className="flex flex-col gap-1.5">
392
+ <p className="text-[10px] font-medium text-muted-foreground">
393
+ {t("designEditor.import.rateLimitAlternatives")}
394
+ </p>
395
+ <Button
396
+ size="sm"
397
+ variant="outline"
398
+ className="h-7 w-full px-2 text-[11px]"
399
+ onClick={() => {
400
+ setFigmaRateLimitError(null);
401
+ setActiveMode("figma-paste");
402
+ }}
403
+ >
404
+ {t("designEditor.import.rateLimitUsePaste")}
405
+ </Button>
406
+ <Button
407
+ size="sm"
408
+ variant="outline"
409
+ className="h-7 w-full px-2 text-[11px]"
410
+ onClick={() => {
411
+ setActiveMode("fig-upload");
412
+ }}
413
+ >
414
+ {t("designEditor.import.rateLimitUseFig")}
415
+ </Button>
416
+ </div>
417
+ {figmaRateLimitError.rateLimitUpgradeUrl ? (
418
+ <a
419
+ href={figmaRateLimitError.rateLimitUpgradeUrl}
420
+ target="_blank"
421
+ rel="noreferrer"
422
+ className="block text-[10px] text-foreground underline-offset-2 hover:underline"
423
+ >
424
+ {t("designEditor.import.rateLimitUpgrade")}
425
+ </a>
426
+ ) : null}
427
+ </div>
428
+ ) : null}
328
429
  <ImportSourceRow
329
430
  id="figma-url-import"
330
431
  icon={<IconBrandFigma className="size-3.5" />}
@@ -437,9 +538,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
437
538
  id="figma-paste-import"
438
539
  icon={<IconBrandFigma className="size-3.5" />}
439
540
  title={t("designEditor.import.figmaPasteTitle")}
440
- description={
441
- "Copy a frame in Figma, then paste into the canvas." /* i18n-ignore */
442
- }
541
+ description={t("designEditor.import.figmaPasteDescription")}
443
542
  isOpen={activeMode === "figma-paste"}
444
543
  onToggle={() =>
445
544
  setActiveMode((mode) =>
@@ -448,12 +547,8 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
448
547
  }
449
548
  >
450
549
  <div className="space-y-1.5 p-2 text-[11px] leading-snug text-muted-foreground">
451
- <p>{t("designEditor.import.figmaPasteDescription")}</p>
452
- <p>
453
- {
454
- "Click the canvas first, then paste with the same shortcut you use for copied Design content." /* i18n-ignore */
455
- }
456
- </p>
550
+ <p>{t("designEditor.import.figmaPasteBodyUnlimited")}</p>
551
+ <p>{t("designEditor.import.figmaPasteBodyImages")}</p>
457
552
  </div>
458
553
  </ImportSourceRow>
459
554
 
@@ -461,7 +556,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
461
556
  id="fig-file-import"
462
557
  icon={<IconUpload className="size-3.5" />}
463
558
  title={t("designEditor.import.figUploadTitle")}
464
- description={t("designEditor.import.figUploadDescription")}
559
+ description={t("designEditor.import.figUploadDescriptionShort")}
465
560
  isOpen={activeMode === "fig-upload"}
466
561
  onToggle={() =>
467
562
  setActiveMode((mode) =>
@@ -0,0 +1,273 @@
1
+ /**
2
+ * FigmaHydrationDialog — shown after a no-token local-kiwi clipboard import
3
+ * when IMAGE fills couldn't be resolved. Collects a Figma access token, saves
4
+ * it, then calls `hydrate-figma-paste-images` for each imported file to
5
+ * replace the `url("about:blank")` placeholders with real durable images.
6
+ */
7
+
8
+ import { callAction } from "@agent-native/core/client/hooks";
9
+ import { useT } from "@agent-native/core/client/i18n";
10
+ import { useEffect, useRef, useState } from "react";
11
+ import { toast } from "sonner";
12
+
13
+ import { Button } from "@/components/ui/button";
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "@/components/ui/dialog";
22
+ import { Input } from "@/components/ui/input";
23
+ import { Label } from "@/components/ui/label";
24
+ import {
25
+ hydrateImagesFromFig,
26
+ validateFigUploadFile,
27
+ } from "@/lib/design-file-upload";
28
+ import {
29
+ getFigmaConnectionStatus,
30
+ saveFigmaAccessToken,
31
+ } from "@/lib/figma-connection";
32
+
33
+ interface FigmaHydrationDialogProps {
34
+ open: boolean;
35
+ onOpenChange: (open: boolean) => void;
36
+ designId: string;
37
+ fileIds: string[];
38
+ imageCount: number;
39
+ onHydrated: () => void;
40
+ }
41
+
42
+ export function FigmaHydrationDialog({
43
+ open,
44
+ onOpenChange,
45
+ designId,
46
+ fileIds,
47
+ imageCount,
48
+ onHydrated,
49
+ }: FigmaHydrationDialogProps) {
50
+ const t = useT();
51
+ const [token, setToken] = useState("");
52
+ const [busy, setBusy] = useState(false);
53
+ const [error, setError] = useState<string | null>(null);
54
+ const [docsUrl, setDocsUrl] = useState<string | null>(null);
55
+ const figInputRef = useRef<HTMLInputElement>(null);
56
+
57
+ const screensPlural = fileIds.length === 1 ? "" : "s";
58
+ const imagePlural = imageCount === 1 ? "" : "s";
59
+
60
+ useEffect(() => {
61
+ if (!open) return;
62
+ getFigmaConnectionStatus()
63
+ .then((status) => {
64
+ if (status.docsUrl) setDocsUrl(status.docsUrl);
65
+ })
66
+ .catch(() => {});
67
+ }, [open]);
68
+
69
+ async function handleFigSelected(e: React.ChangeEvent<HTMLInputElement>) {
70
+ const file = e.target.files?.[0];
71
+ e.target.value = "";
72
+ if (!file) return;
73
+ if (validateFigUploadFile(file)) {
74
+ setError(t("designEditor.import.figmaHydrationInvalidFig"));
75
+ return;
76
+ }
77
+ setBusy(true);
78
+ setError(null);
79
+ try {
80
+ const result = await hydrateImagesFromFig({
81
+ designId,
82
+ file,
83
+ fileIds,
84
+ fallbackErrorMessage: t("designEditor.import.figmaHydrationFigError"),
85
+ });
86
+ if (result.error) {
87
+ setError(result.error);
88
+ return;
89
+ }
90
+ const totalResolved = result.totalResolved ?? 0;
91
+ onOpenChange(false);
92
+ setToken("");
93
+ onHydrated();
94
+ toast.success(t("designEditor.import.figmaHydrationSuccess"), {
95
+ description: t(
96
+ "designEditor.import.figmaHydrationFigSuccessDescription",
97
+ { count: totalResolved, plural: totalResolved === 1 ? "" : "s" },
98
+ ),
99
+ });
100
+ } catch (err) {
101
+ setError(
102
+ err instanceof Error
103
+ ? err.message
104
+ : t("designEditor.import.figmaHydrationFigError"),
105
+ );
106
+ } finally {
107
+ setBusy(false);
108
+ }
109
+ }
110
+
111
+ async function handleSubmit(e: React.FormEvent) {
112
+ e.preventDefault();
113
+ if (!token.trim()) return;
114
+ setBusy(true);
115
+ setError(null);
116
+ try {
117
+ const status = await saveFigmaAccessToken(token.trim());
118
+ if (status.docsUrl) setDocsUrl(status.docsUrl);
119
+
120
+ let totalResolved = 0;
121
+ for (const fileId of fileIds) {
122
+ const result = await callAction<{
123
+ resolved?: number;
124
+ missing?: number;
125
+ }>("hydrate-figma-paste-images", { fileId });
126
+ totalResolved += result?.resolved ?? 0;
127
+ }
128
+
129
+ onOpenChange(false);
130
+ setToken("");
131
+ onHydrated();
132
+ toast.success(t("designEditor.import.figmaHydrationSuccess"), {
133
+ description: t("designEditor.import.figmaHydrationSuccessDescription", {
134
+ count: totalResolved,
135
+ plural: totalResolved === 1 ? "" : "s",
136
+ }),
137
+ });
138
+ } catch (err) {
139
+ const message =
140
+ err instanceof Error ? err.message : t("common.genericError");
141
+ const is403 =
142
+ message.includes("403") || message.toLowerCase().includes("forbidden");
143
+ const isServerError = /internal server error/i.test(message);
144
+ setError(
145
+ is403
146
+ ? 'Token rejected (403). In Figma\'s token settings, enable the "File content" and "Current user" scopes, then generate a new token.'
147
+ : isServerError
148
+ ? "Server error — Figma's API may be rate-limited. Wait ~1 minute then try again; repeated retries extend the cooldown."
149
+ : message,
150
+ );
151
+ } finally {
152
+ setBusy(false);
153
+ }
154
+ }
155
+
156
+ return (
157
+ <Dialog open={open} onOpenChange={onOpenChange}>
158
+ <DialogContent className="sm:max-w-md">
159
+ <form
160
+ onSubmit={(e) => {
161
+ void handleSubmit(e);
162
+ }}
163
+ >
164
+ <DialogHeader>
165
+ <DialogTitle>
166
+ {t("designEditor.import.figmaHydrationDialogTitle")}
167
+ </DialogTitle>
168
+ <DialogDescription>
169
+ {t("designEditor.import.figmaHydrationDialogDescription", {
170
+ count: imageCount,
171
+ plural: imagePlural,
172
+ screensPlural,
173
+ })}
174
+ </DialogDescription>
175
+ </DialogHeader>
176
+
177
+ <div className="mt-4 rounded-md border border-border bg-muted/30 p-3">
178
+ <div className="flex items-center gap-2">
179
+ <p className="text-xs font-medium text-foreground">
180
+ {t("designEditor.import.figmaHydrationFigTitle")}
181
+ </p>
182
+ <span className="rounded-full bg-primary/10 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wide text-primary">
183
+ {t("designEditor.import.figmaHydrationRecommended")}
184
+ </span>
185
+ </div>
186
+ <p className="mt-1 text-[10px] leading-snug text-muted-foreground">
187
+ {t("designEditor.import.figmaHydrationFigOption")}
188
+ </p>
189
+ <input
190
+ ref={figInputRef}
191
+ type="file"
192
+ accept=".fig"
193
+ className="hidden"
194
+ onChange={(e) => void handleFigSelected(e)}
195
+ />
196
+ <Button
197
+ type="button"
198
+ size="sm"
199
+ variant="secondary"
200
+ className="mt-2 w-full"
201
+ disabled={busy}
202
+ onClick={() => figInputRef.current?.click()}
203
+ >
204
+ {t("designEditor.import.figmaHydrationChooseFig")}
205
+ </Button>
206
+ </div>
207
+
208
+ <div className="mt-3 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
209
+ {t("designEditor.import.figmaHydrationOrToken")}
210
+ </div>
211
+
212
+ <div className="mt-2 space-y-2">
213
+ <div className="flex items-center justify-between gap-2">
214
+ <Label htmlFor="figma-hydration-token" className="text-xs">
215
+ {t("designEditor.import.figmaTokenLabel")}
216
+ </Label>
217
+ {docsUrl ? (
218
+ <a
219
+ href={docsUrl}
220
+ target="_blank"
221
+ rel="noreferrer"
222
+ className="shrink-0 text-[10px] font-medium text-foreground underline-offset-2 hover:underline"
223
+ >
224
+ {t("designEditor.import.figmaTokenDocs")}
225
+ </a>
226
+ ) : null}
227
+ </div>
228
+ <Input
229
+ id="figma-hydration-token"
230
+ type="password"
231
+ value={token}
232
+ onChange={(e) => setToken(e.target.value)}
233
+ placeholder={t("designEditor.import.figmaTokenPlaceholder")}
234
+ autoComplete="new-password"
235
+ aria-invalid={error ? true : undefined}
236
+ className="h-8 text-xs"
237
+ disabled={busy}
238
+ />
239
+ {error ? (
240
+ <p className="rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1.5 text-[10px] leading-snug text-destructive">
241
+ {error}
242
+ </p>
243
+ ) : (
244
+ <div className="space-y-1">
245
+ <p className="text-[10px] leading-snug text-muted-foreground">
246
+ {t("designEditor.import.figmaHydrationTokenDescription")}
247
+ </p>
248
+ <p className="text-[10px] leading-snug text-muted-foreground/70">
249
+ {t("designEditor.import.figmaHydrationRateLimit")}
250
+ </p>
251
+ </div>
252
+ )}
253
+ </div>
254
+
255
+ <DialogFooter className="mt-4">
256
+ <Button
257
+ type="button"
258
+ variant="ghost"
259
+ size="sm"
260
+ onClick={() => onOpenChange(false)}
261
+ disabled={busy}
262
+ >
263
+ {t("home.cancel")}
264
+ </Button>
265
+ <Button type="submit" size="sm" disabled={busy || !token.trim()}>
266
+ {t("designEditor.import.figmaHydrationConnectAndLoad")}
267
+ </Button>
268
+ </DialogFooter>
269
+ </form>
270
+ </DialogContent>
271
+ </Dialog>
272
+ );
273
+ }
@@ -455,6 +455,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
455
455
  onBoardElementDblClickText,
456
456
  onBoardIframeHotkey,
457
457
  onBoardFigmaClipboardPaste,
458
+ onBoardImagePaste,
458
459
  onBoardIframeContextMenu,
459
460
  onBoardTextEditingStateChange,
460
461
  boardClearSelectionRequest,
@@ -7670,6 +7671,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7670
7671
  onClearSelection={onBoardElementClear}
7671
7672
  onIframeHotkey={onBoardIframeHotkey}
7672
7673
  onFigmaClipboardPaste={onBoardFigmaClipboardPaste}
7674
+ onImagePaste={onBoardImagePaste}
7673
7675
  onIframeContextMenu={onBoardIframeContextMenu}
7674
7676
  onVisualStructureChange={onBoardVisualStructureChange}
7675
7677
  onVisualStyleChange={onBoardVisualStyleChange}
@@ -9170,14 +9172,12 @@ const Screen = memo(function Screen({
9170
9172
  // Memoize the srcdoc with the hit-test responder injected so we don't
9171
9173
  // rebuild the string every render (that would reload the iframe).
9172
9174
  // Keyed only on screen.content; the hit-test script itself is constant.
9173
- const srcdocWithHitTest = useMemo(
9174
- () =>
9175
- injectSessionReplayIframeBootstrap(
9176
- appendHitTestResponder(screen.content),
9177
- ),
9175
+ const srcdocWithHitTest = useMemo(() => {
9176
+ return injectSessionReplayIframeBootstrap(
9177
+ appendHitTestResponder(screen.content),
9178
+ );
9178
9179
  // eslint-disable-next-line react-hooks/exhaustive-deps
9179
- [screen.content],
9180
- );
9180
+ }, [screen.content]);
9181
9181
 
9182
9182
  const updateDirectHover = useCallback((next: boolean) => {
9183
9183
  setDirectlyHovered((current) => (current === next ? current : next));
@@ -10447,15 +10447,63 @@ declare var __LIVE_REFLOW_ENABLED__: boolean;
10447
10447
  }
10448
10448
  var content = getFigmaClipboardContent(e.clipboardData);
10449
10449
  clearPendingPlainPasteHotkey();
10450
- if (!content) return;
10451
- stopNativeInteraction(e);
10452
- (window.parent as Window).postMessage(
10453
- {
10454
- type: "figma-clipboard-paste",
10455
- content: content,
10456
- },
10457
- "*",
10458
- );
10450
+ if (content) {
10451
+ stopNativeInteraction(e);
10452
+ (window.parent as Window).postMessage(
10453
+ { type: "figma-clipboard-paste", content: content },
10454
+ "*",
10455
+ );
10456
+ return;
10457
+ }
10458
+ // Relay image files pasted while the canvas has focus (e.g. "Copy as PNG"
10459
+ // from Figma, or a screenshot). The parent's handleEditorPaste cannot see
10460
+ // these because paste events inside an iframe don't bubble to the parent
10461
+ // document — the bridge reads each file as a data URL and relays it so
10462
+ // the parent's handlePastedImageFiles can insert an <img> layer.
10463
+ var imageFiles = Array.from(e.clipboardData?.items ?? [])
10464
+ .filter(function (item) {
10465
+ return item.kind === "file" && item.type.startsWith("image/");
10466
+ })
10467
+ .map(function (item) {
10468
+ return item.getAsFile();
10469
+ })
10470
+ .filter(function (f): f is File {
10471
+ return Boolean(f);
10472
+ });
10473
+ if (imageFiles.length > 0) {
10474
+ stopNativeInteraction(e);
10475
+ var readPromises = imageFiles.map(function (file) {
10476
+ return new Promise<{
10477
+ dataUrl: string;
10478
+ type: string;
10479
+ name: string;
10480
+ } | null>(function (resolve) {
10481
+ var reader = new FileReader();
10482
+ reader.onload = function () {
10483
+ resolve({
10484
+ dataUrl: typeof reader.result === "string" ? reader.result : "",
10485
+ type: file.type,
10486
+ name: file.name,
10487
+ });
10488
+ };
10489
+ reader.onerror = function () {
10490
+ resolve(null);
10491
+ };
10492
+ reader.readAsDataURL(file);
10493
+ });
10494
+ });
10495
+ void Promise.all(readPromises).then(function (results) {
10496
+ var valid = results.filter(function (r) {
10497
+ return r && r.dataUrl;
10498
+ });
10499
+ if (valid.length > 0) {
10500
+ (window.parent as Window).postMessage(
10501
+ { type: "canvas-image-paste", files: valid },
10502
+ "*",
10503
+ );
10504
+ }
10505
+ });
10506
+ }
10459
10507
  },
10460
10508
  true,
10461
10509
  );
@@ -14,6 +14,16 @@ export interface IframeFigmaClipboardPastePayload {
14
14
  content: string;
15
15
  }
16
16
 
17
+ export interface IframeImagePasteFile {
18
+ dataUrl: string;
19
+ type: string;
20
+ name: string;
21
+ }
22
+
23
+ export interface IframeImagePastePayload {
24
+ files: IframeImagePasteFile[];
25
+ }
26
+
17
27
  export interface IframeContextMenuPayload {
18
28
  screenId?: string;
19
29
  clientX: number;
@@ -279,7 +279,14 @@ function stripExecutableStaticPreviewContent(html: string) {
279
279
  .replace(/<(?:iframe|object|embed|audio|video|source)\b[^>]*\/?\s*>/gi, "")
280
280
  .replace(/<(?:link|meta|base)\b[^>]*>/gi, "")
281
281
  .replace(/@import\s+(?:url\([^)]*\)|["'][^"']*["'])\s*[^;]*;/gi, "")
282
- .replace(/url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)/gi, "none")
282
+ .replace(/url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)/gi, (match) => {
283
+ // Strip data:, blob:, and unknown-scheme url() references to prevent
284
+ // large embedded payloads or local-resource leaks. Allow https:// URLs
285
+ // (CDN images/fonts) — they can only fetch inert assets, not execute code.
286
+ const raw = match.slice(4, -1).trim();
287
+ const inner = raw.replace(/^['"]|['"]$/g, "").trim();
288
+ return /^https:\/\//i.test(inner) ? match : "none";
289
+ })
283
290
  .replace(/<(?:img|image|use)\b[^>]*>/gi, (tag) =>
284
291
  tag.replace(
285
292
  /\s+(?:src|srcset|href|xlink:href)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi,
@@ -10,6 +10,7 @@ import type {
10
10
  IframeContextMenuPayload,
11
11
  IframeFigmaClipboardPastePayload,
12
12
  IframeHotkeyPayload,
13
+ IframeImagePastePayload,
13
14
  } from "../design-canvas/iframe-events";
14
15
  import type {
15
16
  DeviceFrameType,
@@ -363,6 +364,7 @@ export interface MultiScreenCanvasProps {
363
364
  onBoardFigmaClipboardPaste?: (
364
365
  event: IframeFigmaClipboardPastePayload,
365
366
  ) => void;
367
+ onBoardImagePaste?: (event: IframeImagePastePayload) => void;
366
368
  onBoardIframeContextMenu?: (event: IframeContextMenuPayload) => void;
367
369
  onBoardTextEditingStateChange?: (state: {
368
370
  active: boolean;
@@ -940,6 +942,7 @@ export type {
940
942
  IframeContextMenuPayload,
941
943
  IframeFigmaClipboardPastePayload,
942
944
  IframeHotkeyPayload,
945
+ IframeImagePastePayload,
943
946
  };
944
947
 
945
948
  export interface ResolvedScreenMetadata {