@agent-native/core 0.80.7 → 0.80.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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +70 -27
  5. package/corpus/core/src/client/analytics.ts +3 -1
  6. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  7. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  8. package/corpus/templates/design/AGENTS.md +4 -2
  9. package/corpus/templates/design/actions/edit-design.ts +6 -3
  10. package/corpus/templates/design/actions/generate-screens.ts +5 -3
  11. package/corpus/templates/design/actions/update-design.ts +47 -10
  12. package/corpus/templates/design/actions/update-file.ts +33 -8
  13. package/corpus/templates/design/actions/view-screen.ts +14 -0
  14. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
  15. package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
  16. package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
  17. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
  18. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
  19. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
  20. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
  21. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
  22. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
  23. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  24. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
  25. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
  26. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  27. package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
  28. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  29. package/corpus/templates/design/app/i18n-data.ts +10 -0
  30. package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
  31. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  32. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  33. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  34. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  35. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  36. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  37. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  38. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  39. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  40. package/corpus/templates/design/e2e/README.md +1 -0
  41. package/corpus/templates/design/e2e/global-setup.ts +13 -1
  42. package/corpus/templates/design/playwright.config.ts +10 -1
  43. package/corpus/templates/design/shared/color-utils.ts +159 -0
  44. package/corpus/templates/design/shared/generation-session.ts +1 -0
  45. package/dist/client/AssistantChat.d.ts.map +1 -1
  46. package/dist/client/AssistantChat.js +54 -24
  47. package/dist/client/AssistantChat.js.map +1 -1
  48. package/dist/client/analytics.d.ts.map +1 -1
  49. package/dist/client/analytics.js +2 -1
  50. package/dist/client/analytics.js.map +1 -1
  51. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  52. package/dist/notifications/routes.d.ts +2 -2
  53. package/dist/observability/routes.d.ts +8 -8
  54. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  55. package/package.json +1 -1
@@ -1,5 +1,9 @@
1
1
  import { useT } from "@agent-native/core/client";
2
2
  import {
3
+ DEFAULT_ASSIGNED_REGION_GAP,
4
+ DEFAULT_ASSIGNED_REGION_HEIGHT,
5
+ DEFAULT_ASSIGNED_REGION_MAX_COLUMNS,
6
+ DEFAULT_ASSIGNED_REGION_WIDTH,
3
7
  DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
4
8
  appendPolylinePoint,
5
9
  computeMoveSnap,
@@ -155,6 +159,7 @@ interface MultiScreenCanvasProps {
155
159
  ) => ReactNode;
156
160
  selectAllRequest?: number;
157
161
  clearSelectionRequest?: number;
162
+ onSelectionChange?: (selectedIds: string[]) => void;
158
163
  }
159
164
 
160
165
  /**
@@ -413,6 +418,7 @@ export function MultiScreenCanvas({
413
418
  renderScreenContent,
414
419
  selectAllRequest,
415
420
  clearSelectionRequest,
421
+ onSelectionChange,
416
422
  }: MultiScreenCanvasProps) {
417
423
  const t = useT();
418
424
  const surfaceRef = useRef<HTMLDivElement>(null);
@@ -543,8 +549,14 @@ export function MultiScreenCanvas({
543
549
  frameGeometryRef.current = frameGeometry;
544
550
  }, [frameGeometry]);
545
551
 
552
+ const onSelectionChangeRef = useRef(onSelectionChange);
553
+ useEffect(() => {
554
+ onSelectionChangeRef.current = onSelectionChange;
555
+ }, [onSelectionChange]);
556
+
546
557
  useEffect(() => {
547
558
  selectedIdsRef.current = selectedIds;
559
+ onSelectionChangeRef.current?.(selectedIds);
548
560
  }, [selectedIds]);
549
561
 
550
562
  useEffect(() => {
@@ -704,17 +716,20 @@ export function MultiScreenCanvas({
704
716
  (point: Point) =>
705
717
  getCurrentFrameEntries()
706
718
  .map((entry, index) => ({ ...entry, index }))
707
- .filter((entry) =>
708
- rectContainsPoint(
709
- {
710
- left: entry.geometry.x,
711
- top: entry.geometry.y,
712
- right: entry.geometry.x + entry.geometry.width,
713
- bottom: entry.geometry.y + entry.geometry.height,
714
- },
719
+ .filter((entry) => {
720
+ const bounds = {
721
+ left: entry.geometry.x,
722
+ top: entry.geometry.y,
723
+ right: entry.geometry.x + entry.geometry.width,
724
+ bottom: entry.geometry.y + entry.geometry.height,
725
+ };
726
+ const local = rotatePointAroundCenter(
715
727
  point,
716
- ),
717
- )
728
+ getFrameCenter(entry.geometry),
729
+ entry.geometry.rotation ?? 0,
730
+ );
731
+ return rectContainsPoint(bounds, local);
732
+ })
718
733
  .sort(
719
734
  (a, b) =>
720
735
  (b.geometry.z ?? 0) - (a.geometry.z ?? 0) || b.index - a.index,
@@ -912,12 +927,22 @@ export function MultiScreenCanvas({
912
927
 
913
928
  const hitIds = getCurrentFrameEntries()
914
929
  .filter((entry) =>
915
- rectIntersects(rect, getSelectableBounds(entry.geometry)),
930
+ rotatedRectIntersects(
931
+ rect,
932
+ getSelectableBounds(entry.geometry),
933
+ getFrameCenter(entry.geometry),
934
+ entry.geometry.rotation ?? 0,
935
+ ),
916
936
  )
917
937
  .map((entry) => entry.id);
918
938
  const hitDraftIds = getCurrentDraftEntries()
919
939
  .filter((entry) =>
920
- rectIntersects(rect, getSelectableBounds(entry.geometry)),
940
+ rotatedRectIntersects(
941
+ rect,
942
+ getSelectableBounds(entry.geometry),
943
+ getFrameCenter(entry.geometry),
944
+ entry.geometry.rotation ?? 0,
945
+ ),
921
946
  )
922
947
  .map((entry) => entry.id);
923
948
  updateSelectedIds(() =>
@@ -963,17 +988,21 @@ export function MultiScreenCanvas({
963
988
  if (preferred) return preferred;
964
989
 
965
990
  return entries
966
- .filter(({ geometry }) =>
967
- rectContainsPoint(
968
- {
969
- left: geometry.x,
970
- top: geometry.y,
971
- right: geometry.x + geometry.width,
972
- bottom: geometry.y + geometry.height,
973
- },
974
- getFrameCenter(draft.geometry),
975
- ),
976
- )
991
+ .filter(({ geometry }) => {
992
+ const bounds = {
993
+ left: geometry.x,
994
+ top: geometry.y,
995
+ right: geometry.x + geometry.width,
996
+ bottom: geometry.y + geometry.height,
997
+ };
998
+ const draftCenter = getFrameCenter(draft.geometry);
999
+ const local = rotatePointAroundCenter(
1000
+ draftCenter,
1001
+ getFrameCenter(geometry),
1002
+ geometry.rotation ?? 0,
1003
+ );
1004
+ return rectContainsPoint(bounds, local);
1005
+ })
977
1006
  .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
978
1007
  },
979
1008
  [getCurrentFrameEntries],
@@ -1499,16 +1528,6 @@ export function MultiScreenCanvas({
1499
1528
  );
1500
1529
  const lastNodeId = persisted[persisted.length - 1]?.nodeId;
1501
1530
  if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
1502
- } else {
1503
- // No draft landed in a frame — restore each moved draft to its
1504
- // original position so the move is atomic (commit or revert).
1505
- updateDraftPrimitives((current) =>
1506
- current.map((draft) => {
1507
- if (!state.targetIds.includes(draft.id)) return draft;
1508
- const origin = state.originDrafts[draft.id];
1509
- return origin ?? draft;
1510
- }),
1511
- );
1512
1531
  }
1513
1532
  }
1514
1533
  finishDrag();
@@ -1679,6 +1698,11 @@ export function MultiScreenCanvas({
1679
1698
  if (e.button !== 0 || e.shiftKey) return;
1680
1699
  e.preventDefault();
1681
1700
  e.stopPropagation();
1701
+ // Frame mousedowns stop propagation, so they never reach handleMouseDown.
1702
+ // Clear stale suppression here too so a prior gesture can't swallow this
1703
+ // frame's selecting click. This gesture re-arms suppression on mouse-up
1704
+ // only if it actually moves.
1705
+ suppressNextPick.current = false;
1682
1706
 
1683
1707
  const currentSelectedIds = selectedIdsRef.current;
1684
1708
  const targetIds = currentSelectedIds.includes(id)
@@ -2161,6 +2185,10 @@ export function MultiScreenCanvas({
2161
2185
 
2162
2186
  const handleMouseDown = useCallback(
2163
2187
  (e: React.MouseEvent) => {
2188
+ // Clear any stale pick-suppression left over from a prior resize/rotate/move
2189
+ // gesture that never received its trailing frame click — otherwise it would
2190
+ // silently swallow this unrelated interaction.
2191
+ suppressNextPick.current = false;
2164
2192
  const target = e.target as HTMLElement;
2165
2193
  const onFrame = !!target.closest("[data-frame-shell]");
2166
2194
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
@@ -3080,6 +3108,10 @@ function Screen({
3080
3108
  onMouseDown={(e) => {
3081
3109
  if (e.button !== 0) return;
3082
3110
  if (penActive || creationToolActive) return;
3111
+ if (e.altKey) {
3112
+ onStartDuplicateGesture(screen, display, e);
3113
+ return;
3114
+ }
3083
3115
  if (e.shiftKey) {
3084
3116
  e.stopPropagation();
3085
3117
  onPick(screen.id, e);
@@ -3444,6 +3476,11 @@ interface BoundsRect {
3444
3476
  }
3445
3477
 
3446
3478
  function getInitialFrameGeometry(index: number): FrameGeometry {
3479
+ // Seed default frames with the actual screen dimensions and the 3-column grid
3480
+ // the overview centering math (which uses SCREEN_WIDTH/SCREEN_GAP) expects, so
3481
+ // a design without persisted geometry opens centered. (The larger
3482
+ // assigned-region grid is only for the agent's generation planning, not the
3483
+ // editor's default placement.)
3447
3484
  const column = index % 3;
3448
3485
  const row = Math.floor(index / 3);
3449
3486
  return {
@@ -3571,6 +3608,84 @@ function rectContainsPoint(bounds: BoundsRect, point: Point) {
3571
3608
  );
3572
3609
  }
3573
3610
 
3611
+ /** Rotate `point` into the local (unrotated) space of a frame whose center is
3612
+ * `center` and whose CSS transform is `rotate(degrees deg)`. Passing the
3613
+ * inverse rotation maps world-space coordinates into the frame's local space
3614
+ * so unrotated bounds tests remain correct. */
3615
+ function rotatePointAroundCenter(
3616
+ point: Point,
3617
+ center: Point,
3618
+ degrees: number,
3619
+ ): Point {
3620
+ if (!degrees) return point;
3621
+ const rad = (-degrees * Math.PI) / 180; // inverse rotation
3622
+ const dx = point.x - center.x;
3623
+ const dy = point.y - center.y;
3624
+ const cos = Math.cos(rad);
3625
+ const sin = Math.sin(rad);
3626
+ return {
3627
+ x: center.x + dx * cos - dy * sin,
3628
+ y: center.y + dx * sin + dy * cos,
3629
+ };
3630
+ }
3631
+
3632
+ /** Test whether a marquee rect (in canvas space) intersects an OBB described by
3633
+ * `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
3634
+ * We map the four marquee corners into the frame's local space and check
3635
+ * whether any corner is inside the bounds; we also check the reverse (any
3636
+ * frame corner inside the marquee) to handle the case where the marquee is
3637
+ * entirely contained within the rotated frame. */
3638
+ function rotatedRectIntersects(
3639
+ rect: MarqueeRect,
3640
+ bounds: BoundsRect,
3641
+ center: Point,
3642
+ degrees: number,
3643
+ ): boolean {
3644
+ if (!degrees) {
3645
+ return rectIntersects(rect, bounds);
3646
+ }
3647
+ // Four corners of the marquee rect in canvas space.
3648
+ const marqueeCorners: Point[] = [
3649
+ { x: rect.x, y: rect.y },
3650
+ { x: rect.x + rect.width, y: rect.y },
3651
+ { x: rect.x + rect.width, y: rect.y + rect.height },
3652
+ { x: rect.x, y: rect.y + rect.height },
3653
+ ];
3654
+ // Test: any marquee corner inside the unrotated frame bounds?
3655
+ for (const corner of marqueeCorners) {
3656
+ const local = rotatePointAroundCenter(corner, center, degrees);
3657
+ if (rectContainsPoint(bounds, local)) return true;
3658
+ }
3659
+ // Four corners of the unrotated frame in canvas space (rotate them outward).
3660
+ const frameCorners: Point[] = [
3661
+ { x: bounds.left, y: bounds.top },
3662
+ { x: bounds.right, y: bounds.top },
3663
+ { x: bounds.right, y: bounds.bottom },
3664
+ { x: bounds.left, y: bounds.bottom },
3665
+ ];
3666
+ const rad = (degrees * Math.PI) / 180;
3667
+ const cos = Math.cos(rad);
3668
+ const sin = Math.sin(rad);
3669
+ const marqueeRight = rect.x + rect.width;
3670
+ const marqueeBottom = rect.y + rect.height;
3671
+ // Test: any rotated frame corner inside the marquee rect?
3672
+ for (const fc of frameCorners) {
3673
+ const dx = fc.x - center.x;
3674
+ const dy = fc.y - center.y;
3675
+ const wx = center.x + dx * cos - dy * sin;
3676
+ const wy = center.y + dx * sin + dy * cos;
3677
+ if (
3678
+ wx >= rect.x &&
3679
+ wx <= marqueeRight &&
3680
+ wy >= rect.y &&
3681
+ wy <= marqueeBottom
3682
+ ) {
3683
+ return true;
3684
+ }
3685
+ }
3686
+ return false;
3687
+ }
3688
+
3574
3689
  function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
3575
3690
  return (
3576
3691
  a.x === b.x &&
@@ -60,15 +60,17 @@ export function QuestionFlow({
60
60
  setAnswers((prev) => ({ ...prev, [id]: value }));
61
61
  }, []);
62
62
 
63
- const answeredCount = guidedQuestions.filter((question) =>
64
- hasGuidedAnswer(answers[question.id]),
65
- ).length;
63
+ const isAnswered = (q: GuidedQuestion) => {
64
+ const v = answers[q.id];
65
+ if (q.type === "freeform" && typeof v === "string")
66
+ return v.trim().length > 0;
67
+ return hasGuidedAnswer(v);
68
+ };
69
+ const answeredCount = guidedQuestions.filter(isAnswered).length;
66
70
  const requiredQuestions = guidedQuestions.filter(
67
71
  (question) => question.required,
68
72
  );
69
- const requiredAnswered = requiredQuestions.filter((question) =>
70
- hasGuidedAnswer(answers[question.id]),
71
- ).length;
73
+ const requiredAnswered = requiredQuestions.filter(isAnswered).length;
72
74
  const allRequiredAnswered = requiredAnswered === requiredQuestions.length;
73
75
  const progress =
74
76
  guidedQuestions.length === 0
@@ -554,11 +556,9 @@ function SliderQuestion({
554
556
  const current =
555
557
  typeof value === "number" ? value : Math.round((min + max) / 2);
556
558
 
557
- useEffect(() => {
558
- if (typeof value !== "number") {
559
- onChange(Math.round((min + max) / 2));
560
- }
561
- }, []);
559
+ // Do not auto-fill on mount: a required slider must be explicitly moved by
560
+ // the user before it counts as answered. `current` already provides a
561
+ // display-only midpoint fallback for the rendered slider position.
562
562
 
563
563
  return (
564
564
  <div className="rounded-md border border-border bg-muted/25 px-3 py-3">
@@ -285,19 +285,33 @@ function TweakControl({
285
285
 
286
286
  {tweak.type === "slider" && (
287
287
  <div className="flex h-6 items-center gap-1.5">
288
- <Slider
289
- min={tweak.min ?? 0}
290
- max={tweak.max ?? 100}
291
- step={tweak.step ?? 1}
292
- value={[typeof value === "number" ? value : 50]}
293
- onValueChange={([v]) => onChange(v)}
294
- className="flex-1"
295
- />
296
- <span className="min-w-[2ch] text-right text-[11px] tabular-nums text-muted-foreground">
297
- {typeof value === "number" ? value : 50}
298
- {tweak.unit ??
299
- (tweak.cssVar?.toLowerCase().includes("radius") ? "px" : "")}
300
- </span>
288
+ {(() => {
289
+ const sliderMin = tweak.min ?? 0;
290
+ const sliderMax = tweak.max ?? 100;
291
+ const rawNum = typeof value === "number" ? value : Number(value);
292
+ const sliderValue = Number.isFinite(rawNum)
293
+ ? Math.min(sliderMax, Math.max(sliderMin, rawNum))
294
+ : sliderMin;
295
+ return (
296
+ <>
297
+ <Slider
298
+ min={sliderMin}
299
+ max={sliderMax}
300
+ step={tweak.step ?? 1}
301
+ value={[sliderValue]}
302
+ onValueChange={([v]) => onChange(v)}
303
+ className="flex-1"
304
+ />
305
+ <span className="min-w-[2ch] text-right text-[11px] tabular-nums text-muted-foreground">
306
+ {sliderValue}
307
+ {tweak.unit ??
308
+ (tweak.cssVar?.toLowerCase().includes("radius")
309
+ ? "px"
310
+ : "")}
311
+ </span>
312
+ </>
313
+ );
314
+ })()}
301
315
  </div>
302
316
  )}
303
317
  </div>
@@ -1090,13 +1090,6 @@ export function SizingField({
1090
1090
  }: SizingFieldProps) {
1091
1091
  const labels = { ...DEFAULT_AUTO_LAYOUT_LABELS, ...labelOverrides };
1092
1092
  const isWidth = sizingAxis === "horizontal";
1093
- // Local pending state for a newly-opened constraint editor. Holds the kind
1094
- // and seed value so the sub-row is visible before the user has confirmed a
1095
- // value. We only commit to onMinMaxChange when the user types or scrubs.
1096
- const [pending, setPending] = useState<{
1097
- kind: "min" | "max";
1098
- seed: number;
1099
- } | null>(null);
1100
1093
 
1101
1094
  const minValue = minMax?.min ?? null;
1102
1095
  const maxValue = minMax?.max ?? null;
@@ -1116,12 +1109,11 @@ export function SizingField({
1116
1109
  const maxLabel = isWidth ? labels.maxWidth : labels.maxHeight;
1117
1110
 
1118
1111
  const openEditor = (kind: "min" | "max") => {
1119
- // Compute a sensible seed but do NOT write it to the parent yet — only
1120
- // show the pending sub-row. The value is committed when the user first
1121
- // scrubs/types in the ConstraintSubRow.
1112
+ // Commit immediately so the shown row always reflects real state and
1113
+ // persists across selection changes, remounts, and parent re-renders.
1122
1114
  const seed = Math.max(0, Math.round(resolvedSize ?? 0));
1123
1115
  const seedValue = kind === "min" ? seed : seed || 1;
1124
- setPending({ kind, seed: seedValue });
1116
+ onMinMaxChange?.(sizingAxis, kind, seedValue);
1125
1117
  };
1126
1118
 
1127
1119
  return (
@@ -1236,19 +1228,6 @@ export function SizingField({
1236
1228
  onMinMaxChange?.(sizingAxis, "min", null);
1237
1229
  }}
1238
1230
  />
1239
- ) : pending?.kind === "min" ? (
1240
- // Pending (uncommitted) min row — shown before the user confirms a value.
1241
- <ConstraintSubRow
1242
- label={minLabel}
1243
- value={pending.seed}
1244
- disabled={disabled}
1245
- removeLabel={labels.removeConstraint}
1246
- onChange={(next) => {
1247
- setPending(null);
1248
- onMinMaxChange?.(sizingAxis, "min", next);
1249
- }}
1250
- onRemove={() => setPending(null)}
1251
- />
1252
1231
  ) : null}
1253
1232
  {hasMax ? (
1254
1233
  <ConstraintSubRow
@@ -1261,19 +1240,6 @@ export function SizingField({
1261
1240
  onMinMaxChange?.(sizingAxis, "max", null);
1262
1241
  }}
1263
1242
  />
1264
- ) : pending?.kind === "max" ? (
1265
- // Pending (uncommitted) max row — shown before the user confirms a value.
1266
- <ConstraintSubRow
1267
- label={maxLabel}
1268
- value={pending.seed}
1269
- disabled={disabled}
1270
- removeLabel={labels.removeConstraint}
1271
- onChange={(next) => {
1272
- setPending(null);
1273
- onMinMaxChange?.(sizingAxis, "max", next);
1274
- }}
1275
- onRemove={() => setPending(null)}
1276
- />
1277
1243
  ) : null}
1278
1244
  </div>
1279
1245
  );
@@ -889,7 +889,17 @@ export function DesignColorPicker({
889
889
  setPicking(true);
890
890
  try {
891
891
  const result = await new EyeDropperCtor().open();
892
- if (result.sRGBHex) onChange(result.sRGBHex);
892
+ if (result.sRGBHex) {
893
+ if (activeGradient) {
894
+ // In gradient mode, route to the selected stop (preserving its alpha)
895
+ // like every other color edit — don't replace the whole gradient with
896
+ // a solid hex.
897
+ const parsed = parseCssColor(result.sRGBHex);
898
+ if (parsed) emitStopColor({ ...parsed, a: fieldColor.a });
899
+ } else {
900
+ onChange(result.sRGBHex);
901
+ }
902
+ }
893
903
  } catch {
894
904
  // Browser cancels are expected; keep the current color.
895
905
  } finally {
@@ -1845,8 +1855,14 @@ export function inferPaintType(
1845
1855
  if (
1846
1856
  lower.startsWith("radial-gradient") ||
1847
1857
  lower.startsWith("repeating-radial-gradient")
1848
- )
1858
+ ) {
1859
+ // Diamond fills serialize as a radial gradient (either "closest-corner"
1860
+ // from EditPanel or "ellipse closest-side" from GradientEditor). Recognize
1861
+ // both so a diamond doesn't silently become a plain radial on reselect.
1862
+ if (/closest-corner/.test(lower) || /ellipse\s+closest-side/.test(lower))
1863
+ return "diamond";
1849
1864
  return "radial";
1865
+ }
1850
1866
  if (
1851
1867
  lower.startsWith("conic-gradient") ||
1852
1868
  lower.startsWith("repeating-conic-gradient")
@@ -57,6 +57,11 @@ const DEFAULT_LABELS: ExportSettingsPanelLabels = {
57
57
 
58
58
  const DEFAULT_FORMATS: ExportFormat[] = ["png", "jpg", "svg", "pdf", "webp"];
59
59
 
60
+ // Export scale bounds — must match the clamp in DesignEditor's PNG/SVG export
61
+ // handlers so the panel never promises a range the exporter won't render.
62
+ const MIN_EXPORT_SCALE = 0.1;
63
+ const MAX_EXPORT_SCALE = 4;
64
+
60
65
  /** Preset scale options shown in the scale dropdown — matches the design editor's presets */
61
66
  const SCALE_PRESETS: { label: string; value: ExportScale }[] = [
62
67
  { label: "0.5x", value: "0.5" },
@@ -297,13 +302,20 @@ function ExportRow({
297
302
  type="number"
298
303
  value={row.scale}
299
304
  disabled={isDisabled}
300
- min={0.01}
301
- max={100}
305
+ // Bounds match the exporter's real clamp ([0.1, 4]); see
306
+ // DesignEditor handleDownload* . Previously the field accepted up to
307
+ // 100x but the exporter silently clamped to 4x with no feedback.
308
+ min={MIN_EXPORT_SCALE}
309
+ max={MAX_EXPORT_SCALE}
302
310
  step={0.5}
303
311
  onChange={(e) => {
304
312
  const parsed = parseFloat(e.target.value);
305
313
  if (!Number.isNaN(parsed) && parsed > 0) {
306
- onPatchRow(row.id, { scale: parsed });
314
+ const clamped = Math.min(
315
+ MAX_EXPORT_SCALE,
316
+ Math.max(MIN_EXPORT_SCALE, parsed),
317
+ );
318
+ onPatchRow(row.id, { scale: clamped });
307
319
  }
308
320
  }}
309
321
  onBlur={() => {
@@ -167,10 +167,12 @@ export function parseGradientCss(
167
167
  }
168
168
  } else if (fn === "radial") {
169
169
  kind =
170
- /ellipse\s+closest-side/i.test(first) || fallbackKind === "diamond"
170
+ /ellipse\s+closest-side/i.test(first) ||
171
+ /closest-corner/i.test(first) ||
172
+ fallbackKind === "diamond"
171
173
  ? "diamond"
172
174
  : "radial";
173
- if (/circle|ellipse|at\s/i.test(first)) stopStart = 1;
175
+ if (/circle|ellipse|at\s|closest-/i.test(first)) stopStart = 1;
174
176
  } else if (fn === "conic") {
175
177
  kind = "angular";
176
178
  const angleMatch = first.match(ANGLE_RE);
@@ -34,6 +34,16 @@ const FIT_MODES: Array<{ mode: ImageFitMode; label: string }> = [
34
34
 
35
35
  // ─── CSS serialization ─────────────────────────────────────────────────────────
36
36
 
37
+ /**
38
+ * Escape a URL for embedding inside a double-quoted CSS url("...") token.
39
+ * Only `"` and `\` are CSS-significant inside a double-quoted string; newlines
40
+ * must also be stripped. Everything else (parens, commas, single-quotes, etc.)
41
+ * is safe inside double quotes, so we leave it alone to keep the URL intact.
42
+ */
43
+ function escapeForQuotedUrl(url: string): string {
44
+ return url.replace(/\\/g, "\\\\").replace(/"/g, "%22").replace(/\r?\n/g, "");
45
+ }
46
+
37
47
  const CHECKER_A = "#d4d4d4";
38
48
  const CHECKERBOARD_IMAGE = `linear-gradient(45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(-45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${CHECKER_A} 75%), linear-gradient(-45deg, transparent 75%, ${CHECKER_A} 75%)`;
39
49
  const FIT_MARKER_RE =
@@ -55,7 +65,7 @@ function imageFitMarker(fit: ImageFitMode): string {
55
65
  export function imageFillToCss(value: ImageFillValue): string {
56
66
  const url = value.url.trim();
57
67
  if (!url) return "transparent";
58
- const safeUrl = url.replace(/["')]/g, encodeURIComponent);
68
+ const safeUrl = escapeForQuotedUrl(url);
59
69
  const image = `url("${safeUrl}")`;
60
70
  switch (value.fit) {
61
71
  case "fit":
@@ -70,13 +80,17 @@ export function imageFillToCss(value: ImageFillValue): string {
70
80
  }
71
81
  }
72
82
 
73
- const URL_RE = /url\((['"]?)([^'")]+)\1\)/i;
83
+ // Matches url() in three forms:
84
+ // group 1 — double-quoted: url("...anything...")
85
+ // group 2 — single-quoted: url('...anything...')
86
+ // group 3 — unquoted: url(...no-parens-or-quotes...)
87
+ const URL_RE = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"]*?))\s*\)/i;
74
88
 
75
89
  /** Extract the URL + fit mode from a CSS background value, if present. */
76
90
  export function parseImageFillCss(value: string): ImageFillValue | null {
77
91
  const match = value.match(URL_RE);
78
92
  if (!match) return null;
79
- const url = match[2];
93
+ const url = (match[1] ?? match[2] ?? match[3] ?? "").trim();
80
94
  const marker = value.match(FIT_MARKER_RE)?.[1] as ImageFitMode | undefined;
81
95
  if (marker) return { url, fit: marker };
82
96
  // Heuristic fallback when no marker comment is present (e.g. CSS pasted from
@@ -141,7 +155,7 @@ export function ImageFillControls({
141
155
  className="relative h-24 w-full overflow-hidden rounded-md border border-border/60"
142
156
  style={{
143
157
  backgroundImage: value.url
144
- ? `url("${value.url.trim().replace(/["')]/g, encodeURIComponent)}")`
158
+ ? `url("${escapeForQuotedUrl(value.url.trim())}")`
145
159
  : CHECKERBOARD_IMAGE,
146
160
  backgroundSize: value.url
147
161
  ? value.fit === "fit"
@@ -58,10 +58,16 @@ export function formatScrubValue(
58
58
  options: Pick<ScrubExpressionOptions, "precision" | "unit"> = {},
59
59
  ): string {
60
60
  const normalized = normalizeScrubNumber(value, options);
61
- const numeric =
62
- Number.isFinite(options.precision) && options.precision! >= 0
63
- ? normalized.toFixed(options.precision).replace(/\.?0+$/, "")
64
- : String(normalized);
61
+ let numeric: string;
62
+ if (Number.isFinite(options.precision) && options.precision! >= 0) {
63
+ const fixed = normalized.toFixed(options.precision);
64
+ // Only strip trailing zeros when there's a fractional part. Stripping a bare
65
+ // integer (precision 0, e.g. "100") would mangle it to "1" because the regex
66
+ // eats the integer's own trailing zeros.
67
+ numeric = fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed;
68
+ } else {
69
+ numeric = String(normalized);
70
+ }
65
71
  return `${numeric}${options.unit ?? ""}`;
66
72
  }
67
73