@agent-native/core 0.137.6 → 0.137.8

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 (37) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +65 -18
  3. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  4. package/corpus/templates/clips/desktop/package.json +1 -0
  5. package/corpus/templates/clips/desktop/src/app.tsx +70 -6
  6. package/corpus/templates/clips/desktop/src/components/AlertDialog.tsx +131 -0
  7. package/corpus/templates/clips/desktop/src/styles.css +129 -0
  8. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +2 -0
  9. package/corpus/templates/design/actions/generate-design.ts +3 -2
  10. package/corpus/templates/design/actions/update-design.ts +35 -14
  11. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +7 -2
  12. package/corpus/templates/design/app/components/design/FigmaHydrationDialog.tsx +11 -2
  13. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +8 -1
  14. package/corpus/templates/design/app/i18n-data.ts +53 -22
  15. package/corpus/templates/design/app/lib/design-file-upload.ts +2 -3
  16. package/corpus/templates/design/app/lib/upload-limits.ts +4 -0
  17. package/corpus/templates/design/changelog/2026-08-05-chat-attachments-now-warn-before-upload-when-they-exceed-the.md +6 -0
  18. package/corpus/templates/design/changelog/2026-08-05-figma-fig-uploads-now-show-a-clear-4-mb-limit-up-front-inste.md +6 -0
  19. package/corpus/templates/design/server/handlers/import-design-file.ts +9 -5
  20. package/corpus/templates/design/server/handlers/uploads.ts +13 -9
  21. package/corpus/templates/design/server/lib/fig-file-limits.ts +2 -1
  22. package/corpus/templates/design/server/lib/figma-image-hydration.ts +1 -1
  23. package/corpus/templates/design/server/lib/request-body-limits.ts +11 -0
  24. package/corpus/templates/design/shared/canvas-frames.ts +81 -0
  25. package/dist/client/AssistantChat.js +1 -0
  26. package/dist/client/chat/message-components.d.ts +10 -1
  27. package/dist/client/chat/message-components.js +13 -4
  28. package/dist/client/chat/runtime.js +20 -9
  29. package/dist/client/chat/tool-call-display.d.ts +7 -0
  30. package/dist/client/chat/tool-call-display.js +9 -1
  31. package/dist/client/require-session.js +13 -4
  32. package/dist/client/sse-event-processor.d.ts +2 -0
  33. package/dist/client/sse-event-processor.js +39 -1
  34. package/dist/client/use-session.d.ts +11 -0
  35. package/dist/client/use-session.js +34 -6
  36. package/dist/server/realtime-token.d.ts +1 -1
  37. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 7581
34
+ - template files: 7586
@@ -14,6 +14,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14
14
 
15
15
  import { CaptureInstallInlineLink } from "@/components/capture-install-options";
16
16
  import { ImportMenu } from "@/components/import-menu";
17
+ import {
18
+ AlertDialog,
19
+ AlertDialogAction,
20
+ AlertDialogCancel,
21
+ AlertDialogContent,
22
+ AlertDialogDescription,
23
+ AlertDialogFooter,
24
+ AlertDialogHeader,
25
+ AlertDialogTitle,
26
+ } from "@/components/ui/alert-dialog";
17
27
  import { Button } from "@/components/ui/button";
18
28
  import {
19
29
  Collapsible,
@@ -426,6 +436,35 @@ export function PreRecordPanel({
426
436
  );
427
437
  }, [micId, micLabel, mics, t]);
428
438
 
439
+ const [micWarningOpen, setMicWarningOpen] = useState(false);
440
+
441
+ const buildStartOpts = useCallback(
442
+ () => ({
443
+ // If the user toggled off the camera inside screen+camera mode,
444
+ // downgrade to screen-only so the recorder engine doesn't try
445
+ // to acquire a webcam stream.
446
+ mode: (mode === "screen+camera" && !needsCamera
447
+ ? "screen"
448
+ : mode) as RecordingMode,
449
+ displaySurface: normalizeDisplaySurfaceForRuntime(displaySurface),
450
+ micDeviceId: micId === "default" ? null : micId,
451
+ micDeviceLabel:
452
+ micId === "default" || micId === NO_MIC_DEVICE_ID
453
+ ? null
454
+ : selectedMicLabel,
455
+ cameraDeviceId: needsCamera && cameraId !== "default" ? cameraId : null,
456
+ }),
457
+ [mode, needsCamera, displaySurface, micId, selectedMicLabel, cameraId],
458
+ );
459
+
460
+ const handleStartClick = useCallback(() => {
461
+ if (micId === NO_MIC_DEVICE_ID) {
462
+ setMicWarningOpen(true);
463
+ return;
464
+ }
465
+ onStart(buildStartOpts());
466
+ }, [micId, buildStartOpts, onStart]);
467
+
429
468
  const selectedCameraLabel = useMemo(() => {
430
469
  if (!needsCamera) return null;
431
470
  if (cameraId === "default") return t("preRecord.defaultCamera");
@@ -893,24 +932,7 @@ export function PreRecordPanel({
893
932
  )}
894
933
  <Button
895
934
  disabled={startDisabled}
896
- onClick={() =>
897
- onStart({
898
- // If the user toggled off the camera inside screen+camera mode,
899
- // downgrade to screen-only so the recorder engine doesn't try
900
- // to acquire a webcam stream.
901
- mode:
902
- mode === "screen+camera" && !needsCamera ? "screen" : mode,
903
- displaySurface:
904
- normalizeDisplaySurfaceForRuntime(displaySurface),
905
- micDeviceId: micId === "default" ? null : micId,
906
- micDeviceLabel:
907
- micId === "default" || micId === NO_MIC_DEVICE_ID
908
- ? null
909
- : selectedMicLabel,
910
- cameraDeviceId:
911
- needsCamera && cameraId !== "default" ? cameraId : null,
912
- })
913
- }
935
+ onClick={handleStartClick}
914
936
  className={cn("h-12", onCancel ? "flex-1" : "w-full")}
915
937
  >
916
938
  {t("preRecord.startRecording")}
@@ -943,6 +965,31 @@ export function PreRecordPanel({
943
965
  </>
944
966
  )}
945
967
  </div>
968
+
969
+ <AlertDialog open={micWarningOpen} onOpenChange={setMicWarningOpen}>
970
+ <AlertDialogContent>
971
+ <AlertDialogHeader>
972
+ <AlertDialogTitle>
973
+ {t("preRecord.micOffConfirmTitle")}
974
+ </AlertDialogTitle>
975
+ <AlertDialogDescription>
976
+ {t("preRecord.micOffConfirmDescription")}
977
+ </AlertDialogDescription>
978
+ </AlertDialogHeader>
979
+ <AlertDialogFooter>
980
+ <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
981
+ <AlertDialogAction
982
+ onClick={(event) => {
983
+ event.preventDefault();
984
+ setMicWarningOpen(false);
985
+ onStart(buildStartOpts());
986
+ }}
987
+ >
988
+ {t("preRecord.startWithoutMic")}
989
+ </AlertDialogAction>
990
+ </AlertDialogFooter>
991
+ </AlertDialogContent>
992
+ </AlertDialog>
946
993
  </div>
947
994
  );
948
995
  }
@@ -1274,6 +1274,10 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1274
1274
  cameraOff: "Camera off",
1275
1275
  includeCameraAria: "Include camera in this recording",
1276
1276
  startRecording: "Start recording",
1277
+ micOffConfirmTitle: "Record without a microphone?",
1278
+ micOffConfirmDescription:
1279
+ "Your mic is off, so this recording won't capture any audio. Turn it on before starting if you want narration.",
1280
+ startWithoutMic: "Start anyway",
1277
1281
  uploadVideo: "Upload video",
1278
1282
  importLoom: "Import Loom",
1279
1283
  importing: "Importing...",
@@ -18,6 +18,7 @@
18
18
  "test": "vitest --run"
19
19
  },
20
20
  "dependencies": {
21
+ "@radix-ui/react-alert-dialog": "^1.1.23",
21
22
  "@radix-ui/react-popover": "^1.1.15",
22
23
  "@radix-ui/react-tooltip": "^1.2.8",
23
24
  "@sentry/browser": "10.62.0",
@@ -35,6 +35,16 @@ import {
35
35
  useState,
36
36
  } from "react";
37
37
 
38
+ import {
39
+ AlertDialog,
40
+ AlertDialogAction,
41
+ AlertDialogCancel,
42
+ AlertDialogContent,
43
+ AlertDialogDescription,
44
+ AlertDialogFooter,
45
+ AlertDialogHeader,
46
+ AlertDialogTitle,
47
+ } from "./components/AlertDialog";
38
48
  import { FeedbackButton } from "./components/FeedbackButton";
39
49
  import {
40
50
  CamIcon,
@@ -873,6 +883,9 @@ export function App() {
873
883
  loadBool(CAM_ON_KEY, false),
874
884
  );
875
885
  const [micOn, setMicOn] = useState<boolean>(() => loadBool(MIC_ON_KEY, true));
886
+ const [micOffConfirmOpen, setMicOffConfirmOpen] = useState(false);
887
+ const pendingStartOptionsRef =
888
+ useRef<Parameters<typeof handleStartRecording>[0]>(undefined);
876
889
  const [systemAudioOn, setSystemAudioOn] = useState<boolean>(() =>
877
890
  loadBool(SYSTEM_AUDIO_KEY, true),
878
891
  );
@@ -3051,6 +3064,26 @@ export function App() {
3051
3064
  // include a function that is recreated every render.
3052
3065
  handleStartRecordingRef.current = handleStartRecording;
3053
3066
 
3067
+ // Gates every start-recording gesture (button, global shortcut, permission
3068
+ // retry) on the mic toggle. When the mic is off we hold the actual
3069
+ // getDisplayMedia/getUserMedia call until the user confirms in
3070
+ // micOffConfirmOpen — the confirm button's own click supplies the user
3071
+ // activation handleStartRecording needs, same as the direct gesture would.
3072
+ function beginRecording(
3073
+ options?: Parameters<typeof handleStartRecording>[0],
3074
+ beginOptions?: { revealPopoverIfMicOff?: boolean },
3075
+ ) {
3076
+ if (!micOn) {
3077
+ pendingStartOptionsRef.current = options;
3078
+ if (beginOptions?.revealPopoverIfMicOff) {
3079
+ invoke("show_popover").catch(() => {});
3080
+ }
3081
+ setMicOffConfirmOpen(true);
3082
+ return;
3083
+ }
3084
+ void handleStartRecording(options);
3085
+ }
3086
+
3054
3087
  recordShortcutHandlerRef.current = () => {
3055
3088
  if (recorder) {
3056
3089
  emit("clips:recorder-stop").catch(() => {});
@@ -3078,7 +3111,10 @@ export function App() {
3078
3111
  return;
3079
3112
  }
3080
3113
 
3081
- void handleStartRecording({ ignoreActiveRecorder: true });
3114
+ beginRecording(
3115
+ { ignoreActiveRecorder: true },
3116
+ { revealPopoverIfMicOff: true },
3117
+ );
3082
3118
  };
3083
3119
 
3084
3120
  useEffect(() => {
@@ -3903,9 +3939,7 @@ export function App() {
3903
3939
  disabled={
3904
3940
  localRecordingMode === "off" && videoStorageStatus === "checking"
3905
3941
  }
3906
- onClick={() => {
3907
- void handleStartRecording();
3908
- }}
3942
+ onClick={() => beginRecording()}
3909
3943
  >
3910
3944
  {localRecordingMode === "off" && videoStorageStatus === "checking"
3911
3945
  ? "Checking storage..."
@@ -3914,6 +3948,36 @@ export function App() {
3914
3948
  : "Start local recording"}
3915
3949
  </button>
3916
3950
  ) : null}
3951
+
3952
+ <AlertDialog
3953
+ open={micOffConfirmOpen}
3954
+ onOpenChange={(open) => {
3955
+ setMicOffConfirmOpen(open);
3956
+ if (!open) pendingStartOptionsRef.current = undefined;
3957
+ }}
3958
+ >
3959
+ <AlertDialogContent>
3960
+ <AlertDialogHeader>
3961
+ <AlertDialogTitle>Record without a microphone?</AlertDialogTitle>
3962
+ <AlertDialogDescription>
3963
+ Your mic is off, so this recording won&apos;t capture any audio.
3964
+ Turn it on before starting if you want narration.
3965
+ </AlertDialogDescription>
3966
+ </AlertDialogHeader>
3967
+ <AlertDialogFooter>
3968
+ <AlertDialogAction
3969
+ onClick={() => {
3970
+ const options = pendingStartOptionsRef.current;
3971
+ pendingStartOptionsRef.current = undefined;
3972
+ void handleStartRecording(options);
3973
+ }}
3974
+ >
3975
+ Start anyway
3976
+ </AlertDialogAction>
3977
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
3978
+ </AlertDialogFooter>
3979
+ </AlertDialogContent>
3980
+ </AlertDialog>
3917
3981
  {recError ? (
3918
3982
  recError === MACOS_UPDATE_RESTART_MESSAGE ? (
3919
3983
  <UpdateRestartBanner message={recError} />
@@ -3928,14 +3992,14 @@ export function App() {
3928
3992
  ? ["screen"]
3929
3993
  : permissionPanesForRecording(mode, cameraOn, micOn)
3930
3994
  }
3931
- onRetry={handleStartRecording}
3995
+ onRetry={() => beginRecording()}
3932
3996
  />
3933
3997
  ) : recError === MACOS_SPEECH_PERMISSION_MESSAGE ? (
3934
3998
  <PermissionRecoveryBanner
3935
3999
  kind="speech"
3936
4000
  message={recError}
3937
4001
  panes={["speech", "microphone"]}
3938
- onRetry={handleStartRecording}
4002
+ onRetry={() => beginRecording()}
3939
4003
  />
3940
4004
  ) : isStorageSetupFailureMessage(recError) ? (
3941
4005
  <StorageConnectionBanner
@@ -0,0 +1,131 @@
1
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
2
+ import * as React from "react";
3
+
4
+ /**
5
+ * shadcn-style AlertDialog for the Tauri tray app.
6
+ *
7
+ * Mirrors shadcn/ui's alert-dialog API, but styled with the desktop app's
8
+ * plain-CSS theme tokens instead of Tailwind — this app has no Tailwind/
9
+ * shadcn build. Deliberately skips Radix's `Portal`: the popover window is
10
+ * sized to fit `.app`'s measured content (see `resize_popover`) and clipped
11
+ * to its own rounded corners, so a body-portaled overlay would render past
12
+ * the window's actual bounds. Overlay/Content use `position: absolute`
13
+ * instead of `fixed` so they stay inside `.app`'s rounded, clipped bounds.
14
+ */
15
+
16
+ const AlertDialog = AlertDialogPrimitive.Root;
17
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
18
+
19
+ const AlertDialogOverlay = React.forwardRef<
20
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
21
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
22
+ >(({ className, ...props }, ref) => (
23
+ <AlertDialogPrimitive.Overlay
24
+ ref={ref}
25
+ className={["alert-dialog-overlay", className].filter(Boolean).join(" ")}
26
+ {...props}
27
+ />
28
+ ));
29
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
30
+
31
+ const AlertDialogContent = React.forwardRef<
32
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
33
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
34
+ >(({ className, ...props }, ref) => (
35
+ <>
36
+ <AlertDialogOverlay />
37
+ <AlertDialogPrimitive.Content
38
+ ref={ref}
39
+ className={["alert-dialog-content", className].filter(Boolean).join(" ")}
40
+ {...props}
41
+ />
42
+ </>
43
+ ));
44
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
45
+
46
+ function AlertDialogHeader({
47
+ className,
48
+ ...props
49
+ }: React.HTMLAttributes<HTMLDivElement>) {
50
+ return (
51
+ <div
52
+ className={["alert-dialog-header", className].filter(Boolean).join(" ")}
53
+ {...props}
54
+ />
55
+ );
56
+ }
57
+
58
+ function AlertDialogFooter({
59
+ className,
60
+ ...props
61
+ }: React.HTMLAttributes<HTMLDivElement>) {
62
+ return (
63
+ <div
64
+ className={["alert-dialog-footer", className].filter(Boolean).join(" ")}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+
70
+ const AlertDialogTitle = React.forwardRef<
71
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
72
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
73
+ >(({ className, ...props }, ref) => (
74
+ <AlertDialogPrimitive.Title
75
+ ref={ref}
76
+ className={["alert-dialog-title", className].filter(Boolean).join(" ")}
77
+ {...props}
78
+ />
79
+ ));
80
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
81
+
82
+ const AlertDialogDescription = React.forwardRef<
83
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
84
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
85
+ >(({ className, ...props }, ref) => (
86
+ <AlertDialogPrimitive.Description
87
+ ref={ref}
88
+ className={["alert-dialog-description", className]
89
+ .filter(Boolean)
90
+ .join(" ")}
91
+ {...props}
92
+ />
93
+ ));
94
+ AlertDialogDescription.displayName =
95
+ AlertDialogPrimitive.Description.displayName;
96
+
97
+ const AlertDialogAction = React.forwardRef<
98
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
99
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
100
+ >(({ className, ...props }, ref) => (
101
+ <AlertDialogPrimitive.Action
102
+ ref={ref}
103
+ className={["primary", className].filter(Boolean).join(" ")}
104
+ {...props}
105
+ />
106
+ ));
107
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
108
+
109
+ const AlertDialogCancel = React.forwardRef<
110
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
111
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
112
+ >(({ className, ...props }, ref) => (
113
+ <AlertDialogPrimitive.Cancel
114
+ ref={ref}
115
+ className={["secondary", className].filter(Boolean).join(" ")}
116
+ {...props}
117
+ />
118
+ ));
119
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
120
+
121
+ export {
122
+ AlertDialog,
123
+ AlertDialogTrigger,
124
+ AlertDialogContent,
125
+ AlertDialogHeader,
126
+ AlertDialogFooter,
127
+ AlertDialogTitle,
128
+ AlertDialogDescription,
129
+ AlertDialogAction,
130
+ AlertDialogCancel,
131
+ };
@@ -156,6 +156,7 @@ body[data-clips-route="recording-pill"] #root {
156
156
  content exceeds the restored popover height. Only the content region may
157
157
  scroll; the window shell and bottom destinations stay put. */
158
158
  .app-recorder {
159
+ position: relative;
159
160
  gap: 0;
160
161
  padding: 0;
161
162
  overflow: hidden;
@@ -2612,6 +2613,134 @@ body[data-clips-route="recording-pill"] #root {
2612
2613
  }
2613
2614
  }
2614
2615
 
2616
+ /* ------------------------------------------------------------------------- */
2617
+ /* Alert dialog */
2618
+ /* ------------------------------------------------------------------------- */
2619
+
2620
+ /* `position: absolute`, not `fixed` — see AlertDialog.tsx: this must stay
2621
+ inside `.app-recorder`'s clipped, rounded bounds rather than the raw
2622
+ (square-cornered) Tauri window. */
2623
+ .alert-dialog-overlay {
2624
+ position: absolute;
2625
+ inset: 0;
2626
+ z-index: 70;
2627
+ /* guard:allow-raw-color — modal scrim is theme-invariant black, same as shadcn's own bg-black/80 overlay */
2628
+ background: rgba(0, 0, 0, 0.45);
2629
+ }
2630
+
2631
+ .alert-dialog-overlay[data-state="open"] {
2632
+ animation: alert-dialog-overlay-in 120ms ease-out;
2633
+ }
2634
+
2635
+ .alert-dialog-overlay[data-state="closed"] {
2636
+ animation: alert-dialog-overlay-out 100ms ease-in;
2637
+ }
2638
+
2639
+ .alert-dialog-content {
2640
+ position: absolute;
2641
+ z-index: 71;
2642
+ left: 50%;
2643
+ top: 50%;
2644
+ transform: translate(-50%, -50%);
2645
+ width: calc(100% - 32px);
2646
+ max-width: 320px;
2647
+ border-radius: var(--radius);
2648
+ border: 1px solid var(--border);
2649
+ background: var(--bg);
2650
+ box-shadow: var(--shadow-md);
2651
+ padding: 16px;
2652
+ display: flex;
2653
+ flex-direction: column;
2654
+ gap: 14px;
2655
+ }
2656
+
2657
+ .alert-dialog-content[data-state="open"] {
2658
+ animation: alert-dialog-content-in 120ms ease-out;
2659
+ }
2660
+
2661
+ .alert-dialog-content[data-state="closed"] {
2662
+ animation: alert-dialog-content-out 100ms ease-in;
2663
+ }
2664
+
2665
+ .alert-dialog-header {
2666
+ display: flex;
2667
+ flex-direction: column;
2668
+ gap: 6px;
2669
+ }
2670
+
2671
+ .alert-dialog-title {
2672
+ font-size: 14px;
2673
+ font-weight: 600;
2674
+ color: var(--fg);
2675
+ }
2676
+
2677
+ .alert-dialog-description {
2678
+ font-size: 12px;
2679
+ line-height: 1.5;
2680
+ color: var(--fg-muted);
2681
+ }
2682
+
2683
+ .alert-dialog-footer {
2684
+ display: flex;
2685
+ flex-direction: row-reverse;
2686
+ gap: 8px;
2687
+ }
2688
+
2689
+ .alert-dialog-footer .primary,
2690
+ .alert-dialog-footer .secondary {
2691
+ width: auto;
2692
+ flex: 1;
2693
+ height: 36px;
2694
+ padding: 0 12px;
2695
+ border-radius: var(--radius-sm);
2696
+ font-size: 13px;
2697
+ white-space: nowrap;
2698
+ }
2699
+
2700
+ @keyframes alert-dialog-overlay-in {
2701
+ from {
2702
+ opacity: 0;
2703
+ }
2704
+
2705
+ to {
2706
+ opacity: 1;
2707
+ }
2708
+ }
2709
+
2710
+ @keyframes alert-dialog-overlay-out {
2711
+ from {
2712
+ opacity: 1;
2713
+ }
2714
+
2715
+ to {
2716
+ opacity: 0;
2717
+ }
2718
+ }
2719
+
2720
+ @keyframes alert-dialog-content-in {
2721
+ from {
2722
+ opacity: 0;
2723
+ transform: translate(-50%, -50%) scale(0.96);
2724
+ }
2725
+
2726
+ to {
2727
+ opacity: 1;
2728
+ transform: translate(-50%, -50%) scale(1);
2729
+ }
2730
+ }
2731
+
2732
+ @keyframes alert-dialog-content-out {
2733
+ from {
2734
+ opacity: 1;
2735
+ transform: translate(-50%, -50%) scale(1);
2736
+ }
2737
+
2738
+ to {
2739
+ opacity: 0;
2740
+ transform: translate(-50%, -50%) scale(0.96);
2741
+ }
2742
+ }
2743
+
2615
2744
  .setup-toggle-row {
2616
2745
  display: flex;
2617
2746
  align-items: center;
@@ -330,6 +330,8 @@ canonical sizes to reuse instead of guessing:
330
330
  - **Social**: Instagram Post 1080×1080, Instagram Story 1080×1920, X Post
331
331
  1200×675, Facebook Cover 820×312, LinkedIn Cover 1584×396.
332
332
 
333
+ Frame geometry is always numbers — `"width": 800`, never `"800"` or `"800px"`. String dimensions are rejected.
334
+
333
335
  At small ad-unit sizes (320×50, 160×600), text commonly runs 9-11px — smaller
334
336
  than this skill's general 16px body-text floor — because there is no room to
335
337
  reflow. That is expected for these formats; it stays legible in @2x+ exports
@@ -439,7 +439,7 @@ const generateDesignAgentParameters = {
439
439
  type: "string",
440
440
  description:
441
441
  "Optional JSON array of overview-canvas placements keyed by filename or fileId. " +
442
- "Pass explicit x/y/width/height for every generated screen; desktop is 1440x900.",
442
+ "Pass explicit x/y/width/height for every generated screen as numbers; desktop is 1440x900.",
443
443
  },
444
444
  contextPackId: {
445
445
  type: "string",
@@ -504,7 +504,8 @@ const generateDesignAction = defineAction({
504
504
  "for a mobile- or tablet-primary design when not passing `devices`. " +
505
505
  "Do not report a design as ready until this action succeeds. " +
506
506
  "When adding multiple screens or states, pass canvasFrames with filenames " +
507
- "and x/y/width/height so the new screens appear placed on the overview canvas.",
507
+ "and numeric x/y/width/height so the new screens appear placed on the " +
508
+ "overview canvas.",
508
509
  schema: z.object({
509
510
  designId: z.string().describe("Design project ID to save content to"),
510
511
  prompt: z.string().describe("The generation prompt (stored for reference)"),
@@ -4,6 +4,7 @@ import { and, eq, isNull } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { numericDesignDataWriteError } from "../shared/canvas-frames.js";
7
8
 
8
9
  const MAX_DATA_CAS_ATTEMPTS = 5;
9
10
  const MAX_DATA_OPERATION_SOURCES = 128;
@@ -26,17 +27,28 @@ const dataPathSchema = z
26
27
  .min(1)
27
28
  .max(8);
28
29
 
29
- const dataOperationSchema = z.discriminatedUnion("op", [
30
- z.object({
31
- op: z.literal("set"),
32
- path: dataPathSchema,
33
- value: z.json(),
34
- }),
35
- z.object({
36
- op: z.literal("delete"),
37
- path: dataPathSchema,
38
- }),
39
- ]);
30
+ const dataOperationSchema = z
31
+ .discriminatedUnion("op", [
32
+ z.object({
33
+ op: z.literal("set"),
34
+ path: dataPathSchema,
35
+ value: z.json(),
36
+ }),
37
+ z.object({
38
+ op: z.literal("delete"),
39
+ path: dataPathSchema,
40
+ }),
41
+ ])
42
+ .superRefine((operation, context) => {
43
+ if (operation.op !== "set") return;
44
+ const message = numericDesignDataWriteError(
45
+ operation.path,
46
+ operation.value,
47
+ );
48
+ if (message) {
49
+ context.addIssue({ code: "custom", path: ["value"], message });
50
+ }
51
+ });
40
52
 
41
53
  type DataOperation = z.infer<typeof dataOperationSchema>;
42
54
 
@@ -195,7 +207,9 @@ export default defineAction({
195
207
  "Update an existing design project. Requires editor access. " +
196
208
  "Only provided fields are updated; omitted fields are left unchanged. " +
197
209
  "For map entries such as canvasFrames, use dataOperations " +
198
- "with explicit set/delete paths instead of a full data snapshot.",
210
+ "with explicit set/delete paths instead of a full data snapshot. " +
211
+ "Dimensions and positions (x, y, width, height, rotation, z) are " +
212
+ "numbers. String values are rejected.",
199
213
  schema: z
200
214
  .object({
201
215
  id: z.string().describe("Design ID"),
@@ -213,7 +227,7 @@ export default defineAction({
213
227
  .max(500)
214
228
  .optional()
215
229
  .describe(
216
- "Atomic path-addressed set/delete operations for design data. Safe to CAS-retry across concurrent writers.",
230
+ "Atomic path-addressed set/delete operations for design data. Safe to CAS-retry across concurrent writers. Geometry values must be numbers, not strings.",
217
231
  ),
218
232
  operationSource: z
219
233
  .string()
@@ -284,11 +298,18 @@ export default defineAction({
284
298
  designSystemId,
285
299
  }) => {
286
300
  if (data !== undefined) {
301
+ let parsedSnapshot: unknown;
287
302
  try {
288
- JSON.parse(data);
303
+ parsedSnapshot = JSON.parse(data);
289
304
  } catch {
290
305
  throw new Error("data must be a valid JSON string");
291
306
  }
307
+ if (isRecord(parsedSnapshot)) {
308
+ for (const [key, value] of Object.entries(parsedSnapshot)) {
309
+ const message = numericDesignDataWriteError([key], value);
310
+ if (message) throw new Error(message);
311
+ }
312
+ }
292
313
  }
293
314
 
294
315
  await assertAccess("design", id, "editor");
@@ -37,6 +37,7 @@ import {
37
37
  getFigmaConnectionStatus,
38
38
  saveFigmaAccessToken,
39
39
  } from "@/lib/figma-connection";
40
+ import { MAX_UPLOAD_MB } from "@/lib/upload-limits";
40
41
  import { cn } from "@/lib/utils";
41
42
 
42
43
  import type { DesignExtensionSlotContext } from "./DesignExtensionsPanel";
@@ -307,7 +308,9 @@ export function DesignImportPanel(p: DesignImportPanelProps) {
307
308
  }
308
309
  if (validationError === "too-large") {
309
310
  toast.error(t("designEditor.import.errors.uploadFailed"), {
310
- description: t("designEditor.import.errors.figFileTooLarge"),
311
+ description: t("designEditor.import.errors.figFileTooLarge", {
312
+ max: MAX_UPLOAD_MB,
313
+ }),
311
314
  });
312
315
  if (figFileInputRef.current) figFileInputRef.current.value = "";
313
316
  return;
@@ -566,7 +569,9 @@ export function DesignImportPanel(p: DesignImportPanelProps) {
566
569
  >
567
570
  <div className="space-y-2 p-2">
568
571
  <p className="text-[11px] leading-snug text-muted-foreground">
569
- {t("designEditor.import.figUploadDescription")}
572
+ {t("designEditor.import.figUploadDescription", {
573
+ max: MAX_UPLOAD_MB,
574
+ })}
570
575
  </p>
571
576
  <input
572
577
  ref={figFileInputRef}