@agent-native/core 0.85.6 → 0.85.7

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 (80) 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/use-pinch-zoom.ts +76 -11
  5. package/corpus/core/src/collab/presence.ts +63 -3
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  7. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  8. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  9. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  10. package/corpus/templates/design/AGENTS.md +33 -1
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  12. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  13. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  14. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  15. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  16. package/corpus/templates/design/actions/export-zip.ts +10 -1
  17. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  18. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  19. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  20. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  21. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  22. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  23. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  24. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  26. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  27. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  28. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  29. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
  30. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  32. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  33. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  35. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  36. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  37. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  38. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  39. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  40. package/corpus/templates/design/app/components/design/types.ts +16 -0
  41. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  42. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  43. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  44. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/design/app/i18n-data.ts +18 -0
  46. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  47. package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
  48. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  56. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  57. package/corpus/templates/design/shared/board-file.ts +25 -5
  58. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  59. package/corpus/templates/design/shared/code-layer.ts +304 -26
  60. package/corpus/templates/design/shared/color-utils.ts +84 -0
  61. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  62. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  63. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  64. package/corpus/templates/design/shared/pen-path.ts +200 -23
  65. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  66. package/dist/client/use-pinch-zoom.js +76 -11
  67. package/dist/client/use-pinch-zoom.js.map +1 -1
  68. package/dist/collab/awareness.d.ts +2 -2
  69. package/dist/collab/awareness.d.ts.map +1 -1
  70. package/dist/collab/presence.d.ts.map +1 -1
  71. package/dist/collab/presence.js +54 -3
  72. package/dist/collab/presence.js.map +1 -1
  73. package/dist/collab/routes.d.ts +2 -2
  74. package/dist/collab/struct-routes.d.ts +1 -1
  75. package/dist/observability/routes.d.ts +3 -3
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/resources/handlers.d.ts +2 -2
  78. package/dist/secrets/routes.d.ts +3 -3
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/package.json +1 -1
@@ -67,6 +67,7 @@ import {
67
67
  } from "@tabler/icons-react";
68
68
  import { useQueryClient } from "@tanstack/react-query";
69
69
  import {
70
+ memo,
70
71
  useCallback,
71
72
  useEffect,
72
73
  useMemo,
@@ -82,6 +83,7 @@ import {
82
83
  DropdownMenu,
83
84
  DropdownMenuContent,
84
85
  DropdownMenuItem,
86
+ DropdownMenuSeparator,
85
87
  DropdownMenuTrigger,
86
88
  } from "@/components/ui/dropdown-menu";
87
89
  import { Input } from "@/components/ui/input";
@@ -152,7 +154,7 @@ function sameOrMixed(values: string[]): string {
152
154
  return values.every((value) => value === first) ? first : MIXED_VALUE;
153
155
  }
154
156
 
155
- function mixedElementFromSelection(
157
+ export function mixedElementFromSelection(
156
158
  elements: ElementInfo[],
157
159
  ): ElementInfo | null {
158
160
  const base = elements[elements.length - 1];
@@ -167,6 +169,27 @@ function mixedElementFromSelection(
167
169
  sameOrMixed(elements.map((element) => element.computedStyles[key] ?? "")),
168
170
  ]),
169
171
  );
172
+ // Mix inlineStyles the same way as computedStyles so authoredStyleValue()
173
+ // sees a proper Mixed sentinel across a multi-selection instead of
174
+ // silently inheriting the last-selected element's raw inline value
175
+ // (spreading ...base alone would leak that stale single-element value).
176
+ const inlineStyleKeys = new Set<string>();
177
+ elements.forEach((element) => {
178
+ Object.keys(element.inlineStyles ?? {}).forEach((key) =>
179
+ inlineStyleKeys.add(key),
180
+ );
181
+ });
182
+ const inlineStyles =
183
+ inlineStyleKeys.size > 0
184
+ ? Object.fromEntries(
185
+ Array.from(inlineStyleKeys).map((key) => [
186
+ key,
187
+ sameOrMixed(
188
+ elements.map((element) => element.inlineStyles?.[key] ?? ""),
189
+ ),
190
+ ]),
191
+ )
192
+ : undefined;
170
193
  const minX = Math.min(...elements.map((element) => element.boundingRect.x));
171
194
  const minY = Math.min(...elements.map((element) => element.boundingRect.y));
172
195
  const maxX = Math.max(
@@ -187,6 +210,12 @@ function mixedElementFromSelection(
187
210
  selector: base.selector,
188
211
  classes: [],
189
212
  computedStyles,
213
+ inlineStyles,
214
+ // Mix like tagName above — otherwise isTextElement() would trust
215
+ // base.primitiveKind alone and misclassify a mixed text+shape selection.
216
+ primitiveKind: sameOrMixed(
217
+ elements.map((element) => element.primitiveKind ?? ""),
218
+ ),
190
219
  boundingRect: {
191
220
  x: minX,
192
221
  y: minY,
@@ -393,6 +422,9 @@ function PropInput({
393
422
  }) {
394
423
  const [draft, setDraft] = useState(value);
395
424
  const mixed = isMixedValue(value);
425
+ // Escape reverts and blurs; the blur handler must then skip its commit or it
426
+ // would re-commit the stale draft closure (mirrors ScrubInput's Escape path).
427
+ const skipNextBlurCommitRef = useRef(false);
396
428
 
397
429
  useEffect(() => {
398
430
  setDraft(value);
@@ -431,12 +463,27 @@ function PropInput({
431
463
  // defaultUnit) keep the responsive live-update behavior.
432
464
  if (defaultUnit === undefined) onChange(e.target.value);
433
465
  }}
434
- onBlur={commit}
466
+ onBlur={() => {
467
+ if (skipNextBlurCommitRef.current) {
468
+ skipNextBlurCommitRef.current = false;
469
+ return;
470
+ }
471
+ commit();
472
+ }}
435
473
  onKeyDown={(e) => {
436
474
  if (e.key === "Enter") {
437
475
  e.preventDefault();
438
476
  commit();
439
477
  (e.currentTarget as HTMLInputElement).blur();
478
+ return;
479
+ }
480
+ if (e.key === "Escape") {
481
+ e.preventDefault();
482
+ // Revert the draft to the last committed value and blur, matching
483
+ // ScrubInput's Escape behavior.
484
+ setDraft(value);
485
+ skipNextBlurCommitRef.current = true;
486
+ (e.currentTarget as HTMLInputElement).blur();
440
487
  }
441
488
  }}
442
489
  placeholder={placeholder}
@@ -534,6 +581,19 @@ function ColorInput({
534
581
  }, [activeStopIds, selectedStopId]);
535
582
 
536
583
  const setNext = (next: string) => {
584
+ // Guard rail for callers that don't wire onBackgroundImageChange (i.e.
585
+ // supportsLayeredFills is false, e.g. the text-fill "color" row): the
586
+ // picker manages gradient/image paint-type selection as *local* UI state
587
+ // independent of props (see DesignColorPicker's localPaintType), so a
588
+ // user can still open the Gradient/Image tab there even when this
589
+ // ColorInput never offered layered fills. When that happens,
590
+ // emitPaintValue falls back to this onChange with a full gradient/url()
591
+ // CSS string, which is invalid for a plain color property (color /
592
+ // backgroundColor) and gets silently dropped by the browser — but not
593
+ // before clobbering the last-known-good value in this component's own
594
+ // state. Reject anything that doesn't parse as a plain solid color in
595
+ // that case instead of forwarding it.
596
+ if (!supportsLayeredFills && !parseCssColor(next)) return;
537
597
  setDraft(next);
538
598
  onChange(next);
539
599
  };
@@ -911,6 +971,46 @@ function averageGradientOpacity(stops: DesignGradientStop[]): number {
911
971
  return Math.round(total / stops.length);
912
972
  }
913
973
 
974
+ /**
975
+ * Marker used to non-destructively hide a single backgroundImage layer
976
+ * (gradient or image). CSS strips comments from computed style values — a
977
+ * trailing comment appended to a backgroundImage layer does not survive
978
+ * getComputedStyle (verified: browsers normalize/serialize computed values
979
+ * without their source comments) — so we can't tag the layer text itself.
980
+ * Instead we pair the untouched original layer with a zero-size
981
+ * background-size entry at the same index: `background-size: 0px 0px` makes
982
+ * that layer render nothing while backgroundImage keeps the exact original
983
+ * CSS text. Both backgroundImage and backgroundSize are real, valid,
984
+ * positionally-paired CSS lists that DO round-trip through computed style,
985
+ * so hiding survives reselect/reload with no React state stash required.
986
+ */
987
+ const HIDDEN_LAYER_SIZE_MARKER = "0px 0px";
988
+
989
+ export function isLayerHiddenBySize(sizeEntry: string | undefined): boolean {
990
+ return (
991
+ (sizeEntry ?? "").trim().replace(/\s+/g, " ") === HIDDEN_LAYER_SIZE_MARKER
992
+ );
993
+ }
994
+
995
+ /**
996
+ * Rewrites the background-size list so `index` is hidden/shown via the
997
+ * zero-size marker, padding shorter lists with "auto" (the CSS default) so
998
+ * every other layer keeps rendering at its current/default size.
999
+ */
1000
+ export function withLayerSizeMarker(
1001
+ sizeLayers: string[],
1002
+ layerCount: number,
1003
+ index: number,
1004
+ hidden: boolean,
1005
+ ): string {
1006
+ const next = Array.from(
1007
+ { length: layerCount },
1008
+ (_, i) => sizeLayers[i] || "auto",
1009
+ );
1010
+ next[index] = hidden ? HIDDEN_LAYER_SIZE_MARKER : "auto";
1011
+ return joinCssLayers(next);
1012
+ }
1013
+
914
1014
  function splitCssLayers(value: string): string[] {
915
1015
  const trimmed = value.trim();
916
1016
  if (!trimmed || trimmed === "none") return [];
@@ -1578,8 +1678,30 @@ function parseScaleValue(value: string | undefined): [number, number] {
1578
1678
  return [Number.isFinite(x) ? x : 1, Number.isFinite(y) ? y : 1];
1579
1679
  }
1580
1680
 
1581
- function mergeRotationValue(transform: string | undefined, degrees: number) {
1582
- const nextRotate = `rotate(${Math.round(degrees * 10) / 10}deg)`;
1681
+ /**
1682
+ * Normalize an angle in degrees into the (-180, 180] range the inspector
1683
+ * displays and commits (design-tool convention: 270° reads as -90°, a full
1684
+ * 360° turn reads as 0°). Exported for tests.
1685
+ */
1686
+ export function normalizeRotationDegrees(degrees: number): number {
1687
+ if (!Number.isFinite(degrees)) return 0;
1688
+ let normalized = degrees % 360;
1689
+ if (normalized > 180) normalized -= 360;
1690
+ else if (normalized <= -180) normalized += 360;
1691
+ return Object.is(normalized, -0) ? 0 : normalized;
1692
+ }
1693
+
1694
+ /** Exported for tests. */
1695
+ export function mergeRotationValue(
1696
+ transform: string | undefined,
1697
+ degrees: number,
1698
+ ) {
1699
+ // Round first, then normalize, so a rounded -179.96 → -180 still lands
1700
+ // inside (-180, 180] as +180.
1701
+ const normalizedDegrees = normalizeRotationDegrees(
1702
+ Math.round(degrees * 10) / 10,
1703
+ );
1704
+ const nextRotate = `rotate(${normalizedDegrees}deg)`;
1583
1705
  if (!transform || transform === "none") return nextRotate;
1584
1706
  // Replace an existing rotate()/rotateZ() in ANY unit so we don't append a
1585
1707
  // second rotate() (which would compound, e.g. "rotate(0.5turn) rotate(30deg)").
@@ -1695,6 +1817,11 @@ function cssLengthNumber(value: string | undefined, fallback = 0): number {
1695
1817
  return Number.isFinite(parsed) ? parsed : fallback;
1696
1818
  }
1697
1819
 
1820
+ /** Round to one decimal place — matches the `precision={1}` ScrubInput controls advertise (e.g. stroke weight, font size) so 0.5-unit values aren't silently floored to whole numbers. */
1821
+ export function roundToOneDecimal(value: number): number {
1822
+ return Math.round(value * 10) / 10;
1823
+ }
1824
+
1698
1825
  function cssColorOrFallback(value: string | undefined, fallback: string) {
1699
1826
  const normalized = value?.trim();
1700
1827
  if (
@@ -1711,6 +1838,17 @@ function strokeIsVisible(width: string | undefined, style: string | undefined) {
1711
1838
  return cssLengthNumber(width) > 0 && style !== "none";
1712
1839
  }
1713
1840
 
1841
+ /**
1842
+ * True when a stroke's own color is the reason it's invisible (alpha
1843
+ * zeroed) rather than its width/style. Used so the eye toggle can hide a
1844
+ * stroke by zeroing color alpha — preserving the original border-style
1845
+ * (solid/dashed/dotted/etc, which has no "unset" round-trip once written as
1846
+ * "none") — instead of forcing borderStyle back to "solid" on every show.
1847
+ */
1848
+ export function strokeHiddenByColor(color: string | undefined): boolean {
1849
+ return Boolean(color) && !colorHasVisibleAlpha(color);
1850
+ }
1851
+
1714
1852
  function swatchStyle(value: string | undefined) {
1715
1853
  return {
1716
1854
  background:
@@ -1894,8 +2032,29 @@ function parentFlexDirection(element: ElementInfo): AutoLayoutSizingAxis {
1894
2032
  : "horizontal";
1895
2033
  }
1896
2034
 
1897
- function isTextElement(element: ElementInfo): boolean {
1898
- return TEXT_TAGS.has((element.tagName || "").toLowerCase());
2035
+ export function isTextElement(element: ElementInfo): boolean {
2036
+ const tag = (element.tagName || "").toLowerCase();
2037
+ if (TEXT_TAGS.has(tag)) return true;
2038
+ // T-tool text primitives are plain `div`s stamped with
2039
+ // data-an-primitive="text" (see DesignEditor primitive creation). The
2040
+ // bridge now forwards that marker as ElementInfo.primitiveKind — prefer it
2041
+ // when present since it's exact.
2042
+ if (element.primitiveKind) return element.primitiveKind === "text";
2043
+ // Fallback for older payloads that predate primitiveKind: approximate a
2044
+ // T-tool text div with a content heuristic — a childless div that has its
2045
+ // own text content and isn't already flagged as a layout container. This
2046
+ // intentionally excludes empty frames/shapes (no text) and containers with
2047
+ // element children.
2048
+ if (
2049
+ tag === "div" &&
2050
+ !element.isFlexContainer &&
2051
+ !element.isGridContainer &&
2052
+ (element.childElementCount ?? 0) === 0 &&
2053
+ Boolean(element.textContent?.trim())
2054
+ ) {
2055
+ return true;
2056
+ }
2057
+ return false;
1899
2058
  }
1900
2059
 
1901
2060
  /**
@@ -3331,36 +3490,80 @@ function CornerRadiusControl({
3331
3490
  }) {
3332
3491
  const t = useT();
3333
3492
  const independentCornersLabel = t("editPanel.labels.independentCorners");
3493
+ const cornerSources = {
3494
+ topLeft: styles.borderTopLeftRadius || styles.borderRadius,
3495
+ topRight: styles.borderTopRightRadius || styles.borderRadius,
3496
+ bottomRight: styles.borderBottomRightRadius || styles.borderRadius,
3497
+ bottomLeft: styles.borderBottomLeftRadius || styles.borderRadius,
3498
+ };
3499
+ const cornerMixed = {
3500
+ topLeft: isMixedValue(cornerSources.topLeft),
3501
+ topRight: isMixedValue(cornerSources.topRight),
3502
+ bottomRight: isMixedValue(cornerSources.bottomRight),
3503
+ bottomLeft: isMixedValue(cornerSources.bottomLeft),
3504
+ };
3505
+ // Guard cssLengthNumber against the Mixed sentinel — parseFloat("Mixed")
3506
+ // would silently coerce it to 0 and render a concrete value.
3334
3507
  const corners = {
3335
- topLeft: cssLengthNumber(styles.borderTopLeftRadius || styles.borderRadius),
3336
- topRight: cssLengthNumber(
3337
- styles.borderTopRightRadius || styles.borderRadius,
3338
- ),
3339
- bottomRight: cssLengthNumber(
3340
- styles.borderBottomRightRadius || styles.borderRadius,
3341
- ),
3342
- bottomLeft: cssLengthNumber(
3343
- styles.borderBottomLeftRadius || styles.borderRadius,
3344
- ),
3508
+ topLeft: cornerMixed.topLeft ? 0 : cssLengthNumber(cornerSources.topLeft),
3509
+ topRight: cornerMixed.topRight
3510
+ ? 0
3511
+ : cssLengthNumber(cornerSources.topRight),
3512
+ bottomRight: cornerMixed.bottomRight
3513
+ ? 0
3514
+ : cssLengthNumber(cornerSources.bottomRight),
3515
+ bottomLeft: cornerMixed.bottomLeft
3516
+ ? 0
3517
+ : cssLengthNumber(cornerSources.bottomLeft),
3345
3518
  };
3346
- const cornersDiffer =
3347
- corners.topLeft !== corners.topRight ||
3348
- corners.topLeft !== corners.bottomRight ||
3349
- corners.topLeft !== corners.bottomLeft;
3519
+ const anyCornerMixed =
3520
+ cornerMixed.topLeft ||
3521
+ cornerMixed.topRight ||
3522
+ cornerMixed.bottomRight ||
3523
+ cornerMixed.bottomLeft;
3524
+ const allCornersMixed =
3525
+ cornerMixed.topLeft &&
3526
+ cornerMixed.topRight &&
3527
+ cornerMixed.bottomRight &&
3528
+ cornerMixed.bottomLeft;
3529
+ // With mixed sentinels the parsed numbers are placeholders, so compare
3530
+ // mixed-ness instead: all-mixed reads as uniform (each element may still be
3531
+ // uniform), partially-mixed means at least one element has differing corners.
3532
+ const cornersDiffer = anyCornerMixed
3533
+ ? !allCornersMixed
3534
+ : corners.topLeft !== corners.topRight ||
3535
+ corners.topLeft !== corners.bottomRight ||
3536
+ corners.topLeft !== corners.bottomLeft;
3350
3537
  const [showIndependentCorners, setShowIndependentCorners] =
3351
3538
  useState(cornersDiffer);
3352
- const radius = cornersDiffer
3353
- ? corners.topLeft
3354
- : cssLengthNumber(styles.borderRadius || String(corners.topLeft));
3539
+ const radiusMixed =
3540
+ anyCornerMixed || (!cornersDiffer && isMixedValue(styles.borderRadius));
3541
+ const radius = radiusMixed
3542
+ ? 0
3543
+ : cornersDiffer
3544
+ ? corners.topLeft
3545
+ : cssLengthNumber(styles.borderRadius || String(corners.topLeft));
3355
3546
  const commitRadius = (value: number) => {
3356
3547
  const next = `${Math.max(0, Math.round(value))}px`;
3548
+ // Always write the longhands along with the shorthand: stale inline
3549
+ // longhand declarations serialize after the shorthand and would override
3550
+ // it, turning uniform-radius commits into silent no-ops.
3357
3551
  onStyleChange("borderRadius", next);
3358
- if (!showIndependentCorners) return;
3359
3552
  onStyleChange("borderTopLeftRadius", next);
3360
3553
  onStyleChange("borderTopRightRadius", next);
3361
3554
  onStyleChange("borderBottomRightRadius", next);
3362
3555
  onStyleChange("borderBottomLeftRadius", next);
3363
3556
  };
3557
+ const toggleIndependentCorners = () => {
3558
+ // Collapsing while corners differ flattens them to the displayed uniform
3559
+ // value; otherwise the stale longhands would keep overriding the shorthand
3560
+ // and the single field would silently no-op. Mixed selections collapse the
3561
+ // UI only — committing would stamp the placeholder 0 onto every object.
3562
+ if (showIndependentCorners && cornersDiffer && !radiusMixed) {
3563
+ commitRadius(radius);
3564
+ }
3565
+ setShowIndependentCorners(!showIndependentCorners);
3566
+ };
3364
3567
 
3365
3568
  useEffect(() => {
3366
3569
  if (cornersDiffer) setShowIndependentCorners(true);
@@ -3373,6 +3576,7 @@ function CornerRadiusControl({
3373
3576
  icon={IconBorderRadius}
3374
3577
  value={radius}
3375
3578
  onChange={commitRadius}
3579
+ mixed={radiusMixed}
3376
3580
  min={0}
3377
3581
  precision={0}
3378
3582
  />
@@ -3389,7 +3593,7 @@ function CornerRadiusControl({
3389
3593
  )}
3390
3594
  aria-label={independentCornersLabel}
3391
3595
  aria-pressed={showIndependentCorners}
3392
- onClick={() => setShowIndependentCorners((value) => !value)}
3596
+ onClick={toggleIndependentCorners}
3393
3597
  >
3394
3598
  <IconBorderCorners className="size-3.5" />
3395
3599
  </Button>
@@ -3409,6 +3613,7 @@ function CornerRadiusControl({
3409
3613
  `${Math.max(0, Math.round(value))}px`,
3410
3614
  )
3411
3615
  }
3616
+ mixed={cornerMixed.topLeft}
3412
3617
  min={0}
3413
3618
  precision={1}
3414
3619
  />
@@ -3423,6 +3628,7 @@ function CornerRadiusControl({
3423
3628
  `${Math.max(0, Math.round(value))}px`,
3424
3629
  )
3425
3630
  }
3631
+ mixed={cornerMixed.topRight}
3426
3632
  min={0}
3427
3633
  precision={1}
3428
3634
  />
@@ -3438,6 +3644,7 @@ function CornerRadiusControl({
3438
3644
  `${Math.max(0, Math.round(value))}px`,
3439
3645
  )
3440
3646
  }
3647
+ mixed={cornerMixed.bottomLeft}
3441
3648
  min={0}
3442
3649
  precision={1}
3443
3650
  />
@@ -3452,6 +3659,7 @@ function CornerRadiusControl({
3452
3659
  `${Math.max(0, Math.round(value))}px`,
3453
3660
  )
3454
3661
  }
3662
+ mixed={cornerMixed.bottomRight}
3455
3663
  min={0}
3456
3664
  precision={1}
3457
3665
  />
@@ -3520,8 +3728,16 @@ function BlendModeMenu({
3520
3728
  styles.mixBlendMode || "normal",
3521
3729
  "normal",
3522
3730
  );
3523
- const selectedBlendMode =
3524
- blendMode === "normal" && styles.isolation !== "isolate"
3731
+ // Recognize the Mixed sentinel BEFORE optionValue's fallback maps it to
3732
+ // "normal" a mixed selection must not check a wrong concrete mode.
3733
+ // Isolation only disambiguates pass-through vs normal, so it only makes the
3734
+ // state mixed when the blend mode itself resolves to normal.
3735
+ const blendModeMixed =
3736
+ isMixedValue(styles.mixBlendMode) ||
3737
+ (blendMode === "normal" && isMixedValue(styles.isolation));
3738
+ const selectedBlendMode = blendModeMixed
3739
+ ? MIXED_VALUE
3740
+ : blendMode === "normal" && styles.isolation !== "isolate"
3525
3741
  ? "pass-through"
3526
3742
  : blendMode;
3527
3743
  const options = [
@@ -3565,6 +3781,20 @@ function BlendModeMenu({
3565
3781
  sideOffset={8}
3566
3782
  className="z-[100010] w-48 rounded-xl border-[var(--design-editor-control-border)] bg-[var(--design-editor-panel-bg)] p-1 text-[13px] text-foreground shadow-2xl"
3567
3783
  >
3784
+ {blendModeMixed ? (
3785
+ <>
3786
+ {/* Placeholder state for a mixed selection: the check sits next to
3787
+ "Mixed" instead of a wrong concrete mode. Picking any option
3788
+ below applies it to every selected object. */}
3789
+ <div className="flex h-9 items-center gap-3 rounded-md px-3 text-[13px] text-muted-foreground">
3790
+ <span className="flex size-4 shrink-0 items-center justify-center">
3791
+ <IconCheck className="size-4" />
3792
+ </span>
3793
+ <span>{MIXED_VALUE}</span>
3794
+ </div>
3795
+ <DropdownMenuSeparator />
3796
+ </>
3797
+ ) : null}
3568
3798
  {options.map((option) => (
3569
3799
  <DropdownMenuItem
3570
3800
  key={option.value}
@@ -3641,11 +3871,25 @@ function StrokeLayerControl({
3641
3871
  : t("editPanel.labels.showLayer")
3642
3872
  }
3643
3873
  onClick={() => {
3874
+ // Hide/show by zeroing the stroke color's alpha (preserving its
3875
+ // RGB channels — same durable, comment-free technique as the
3876
+ // fill visibility toggle) instead of forcing borderStyle to
3877
+ // "none"/"solid". Writing "none" would lose a dashed/dotted
3878
+ // style permanently, since there is no round-trippable "unset"
3879
+ // for that keyword once it's overwritten.
3880
+ const parsed = parseCssColor(color);
3644
3881
  if (visible) {
3645
- onStyleChange(`${prefix}Style`, "none");
3882
+ onStyleChange(
3883
+ `${prefix}Color`,
3884
+ parsed ? rgbaToCss(withColorOpacity(parsed, 0)) : "transparent",
3885
+ );
3646
3886
  return;
3647
3887
  }
3648
- onStyleChange(`${prefix}Style`, "solid");
3888
+ const restoredColor = parsed
3889
+ ? rgbaToCss(withColorOpacity(parsed, 100))
3890
+ : "#000000";
3891
+ onStyleChange(`${prefix}Color`, restoredColor);
3892
+ if (styleValue === "none") onStyleChange(`${prefix}Style`, "solid");
3649
3893
  onStyleChange(
3650
3894
  `${prefix}Width`,
3651
3895
  width === "0px" ? "1px" : width || "1px",
@@ -3691,7 +3935,7 @@ function StrokeLayerControl({
3691
3935
  onChange={(value) =>
3692
3936
  onStyleChange(
3693
3937
  `${prefix}Width`,
3694
- `${Math.max(0, Math.round(value))}px`,
3938
+ `${Math.max(0, roundToOneDecimal(value))}px`,
3695
3939
  )
3696
3940
  }
3697
3941
  unit="px"
@@ -3789,7 +4033,9 @@ function serializeShadowLayers(layers: ShadowLayer[]) {
3789
4033
  `${Math.round(layer.x)}px`,
3790
4034
  `${Math.round(layer.y)}px`,
3791
4035
  `${Math.max(0, Math.round(layer.blur))}px`,
3792
- `${layer.inset ? Math.round(layer.spread) : Math.max(0, Math.round(layer.spread))}px`,
4036
+ // Spread radius may legitimately be negative for either inset or
4037
+ // drop shadows — only blur-radius is clamped to >= 0 in CSS.
4038
+ `${Math.round(layer.spread)}px`,
3793
4039
  layer.color,
3794
4040
  ]
3795
4041
  .filter(Boolean)
@@ -3937,11 +4183,12 @@ function ShadowEffectRow({
3937
4183
  <ScrubInput
3938
4184
  label={t("editPanel.labels.spread")}
3939
4185
  value={layer.spread}
3940
- onChange={(value) =>
3941
- onChange({ spread: layer.inset ? value : Math.max(0, value) })
3942
- }
4186
+ // Spread radius is valid negative for both inset AND drop
4187
+ // (non-inset) shadows in real CSS negative spread shrinks
4188
+ // the shadow smaller than the box before blurring, a common
4189
+ // technique. Only blur-radius must stay >= 0.
4190
+ onChange={(value) => onChange({ spread: value })}
3943
4191
  unit="px"
3944
- min={layer.inset ? undefined : 0}
3945
4192
  precision={1}
3946
4193
  inputClassName="h-6"
3947
4194
  />
@@ -4058,16 +4305,48 @@ function TypographyProperties({
4058
4305
  }));
4059
4306
  const textAlign = styles.textAlign || "left";
4060
4307
 
4061
- // M1 · Text resizing mode (auto-width / auto-height / fixed). the design editor's text
4062
- // nodes always expose this segment. Infer the current mode from the live CSS:
4063
- // auto-width hugs both axes (width:auto + no wrapping), auto-height hugs the
4064
- // height only (fixed width, content wraps), fixed pins both width and height.
4065
- const widthIsAuto =
4066
- !styles.width || styles.width === "auto" || styles.width === "max-content";
4067
- const heightIsAuto = !styles.height || styles.height === "auto";
4068
- const noWrap = styles.whiteSpace === "nowrap";
4308
+ // Mixed-selection guards: a multi-selection with differing values injects
4309
+ // the MIXED_VALUE sentinel string into these computedStyles fields (see
4310
+ // mixedElementFromSelection/sameOrMixed). Parsing that sentinel with
4311
+ // parseNumericValue/Number() silently yields 0/NaN-fallback instead of
4312
+ // reflecting "differs across selection", which previously showed a
4313
+ // fabricated 0 (size), 1.2 (line-height), or blank (tracking) rather than
4314
+ // the Mixed state ScrubInput already knows how to render — same pattern as
4315
+ // the rotation field above.
4316
+ const fontWeightIsMixed = isMixedValue(styles.fontWeight);
4317
+ const fontSizeIsMixed = isMixedValue(styles.fontSize);
4318
+ const lineHeightIsMixed = isMixedValue(styles.lineHeight);
4319
+ const letterSpacingIsMixed = isMixedValue(styles.letterSpacing);
4320
+
4321
+ // M1 · Text resizing mode (auto-width / auto-height / fixed). the design
4322
+ // editor's text nodes always expose this segment. Read authored
4323
+ // (inlineStyles) values, not computed ones: an absolutely-positioned
4324
+ // element's computed width/height always resolve to a real px value even
4325
+ // when the author never set them, so "auto" and "a specific 200px" were
4326
+ // indistinguishable before — every text node misread as "fixed". Falls
4327
+ // back to the computed-style heuristic for older payloads that predate
4328
+ // inlineStyles. Convention (matches DesignEditor primitive creation and
4329
+ // setResizeMode below): auto-width = width unset/max-content + pre-wrap;
4330
+ // auto-height = fixed width + height unset/auto; fixed = both fixed. A
4331
+ // drag-created box (display:flex, explicit width+height, whiteSpace
4332
+ // unset→normal) correctly falls through to "fixed".
4333
+ const authoredResizeWidth = authoredStyleValue(element, "width");
4334
+ const authoredResizeHeight = authoredStyleValue(element, "height");
4335
+ const authoredWhiteSpace = authoredStyleValue(element, "whiteSpace");
4336
+ const hasInlineStyleInfo = Boolean(element.inlineStyles);
4337
+ const widthIsAuto = hasInlineStyleInfo
4338
+ ? !authoredResizeWidth || authoredResizeWidth === "max-content"
4339
+ : !styles.width ||
4340
+ styles.width === "auto" ||
4341
+ styles.width === "max-content";
4342
+ const heightIsAuto = hasInlineStyleInfo
4343
+ ? !authoredResizeHeight || authoredResizeHeight === "auto"
4344
+ : !styles.height || styles.height === "auto";
4345
+ const isPreWrapOrNoWrap = hasInlineStyleInfo
4346
+ ? authoredWhiteSpace === "pre-wrap" || authoredWhiteSpace === "nowrap"
4347
+ : styles.whiteSpace === "nowrap";
4069
4348
  const resizeMode: TextResizeMode =
4070
- widthIsAuto && noWrap
4349
+ widthIsAuto && isPreWrapOrNoWrap
4071
4350
  ? "auto-width"
4072
4351
  : !heightIsAuto && !widthIsAuto
4073
4352
  ? "fixed"
@@ -4076,9 +4355,9 @@ function TypographyProperties({
4076
4355
  const currentHeight = styles.height && !heightIsAuto ? styles.height : "48px";
4077
4356
  const setResizeMode = (mode: TextResizeMode) => {
4078
4357
  if (mode === "auto-width") {
4079
- onStyleChange("width", "auto");
4358
+ onStyleChange("width", "max-content");
4080
4359
  onStyleChange("height", "auto");
4081
- onStyleChange("whiteSpace", "nowrap");
4360
+ onStyleChange("whiteSpace", "pre-wrap");
4082
4361
  } else if (mode === "auto-height") {
4083
4362
  onStyleChange("width", currentWidth);
4084
4363
  onStyleChange("height", "auto");
@@ -4090,36 +4369,44 @@ function TypographyProperties({
4090
4369
  }
4091
4370
  };
4092
4371
 
4093
- // M2 · Vertical text alignment (top / middle / bottom). For auto-layout text
4094
- // containers (display:flex) the design editor maps this to `justifyContent`; for normal
4095
- // text we fall back to `verticalAlign`, which is what an inline/grid text box
4096
- // honors. Read whichever the element currently expresses.
4372
+ // M2 · Vertical text alignment (top / middle / bottom). For an auto-layout
4373
+ // text container (display:flex) this maps to whichever flex property
4374
+ // controls the vertical/cross axis justifyContent when flex-direction is
4375
+ // column, alignItems when row (the DesignEditor drag-created default; see
4376
+ // primitive creation, which sets display:flex + alignItems:center with no
4377
+ // explicit flex-direction, i.e. row). For any non-flex display,
4378
+ // `verticalAlign` is a no-op: it only affects how an inline/inline-block/
4379
+ // table-cell box sits relative to *sibling* line-box content, not how its
4380
+ // own content sits within its own box — exactly the case for point text
4381
+ // (inline-block). So instead of ever writing verticalAlign, convert the
4382
+ // element to flex the same way a drag-created box is authored, then read/
4383
+ // write through the row-axis property (alignItems) like that default.
4097
4384
  const display = (styles.display || "").toLowerCase();
4098
4385
  const isFlexText = display.includes("flex");
4099
- const verticalAlign = isFlexText
4100
- ? styles.justifyContent === "center"
4101
- ? "middle"
4102
- : styles.justifyContent === "flex-end"
4103
- ? "bottom"
4104
- : "top"
4105
- : styles.verticalAlign === "middle"
4386
+ const isColumnFlexText =
4387
+ isFlexText && styles.flexDirection?.includes("column");
4388
+ const verticalAlignSourceProp = isColumnFlexText
4389
+ ? styles.justifyContent
4390
+ : styles.alignItems;
4391
+ const verticalAlign = !isFlexText
4392
+ ? "top"
4393
+ : verticalAlignSourceProp === "center"
4106
4394
  ? "middle"
4107
- : styles.verticalAlign === "bottom"
4395
+ : verticalAlignSourceProp === "flex-end"
4108
4396
  ? "bottom"
4109
4397
  : "top";
4110
4398
  const setVerticalAlign = (mode: "top" | "middle" | "bottom") => {
4111
- if (isFlexText) {
4112
- onStyleChange(
4113
- "justifyContent",
4114
- mode === "middle"
4115
- ? "center"
4116
- : mode === "bottom"
4117
- ? "flex-end"
4118
- : "flex-start",
4119
- );
4120
- } else {
4121
- onStyleChange("verticalAlign", mode);
4122
- }
4399
+ // Converting a non-flex element matches the drag-created fixed-size text
4400
+ // box exactly: display:flex, default (row) flex-direction — so the
4401
+ // vertical axis is alignItems, same as the pre-existing row case below.
4402
+ if (!isFlexText) onStyleChange("display", "flex");
4403
+ const cssValue =
4404
+ mode === "middle"
4405
+ ? "center"
4406
+ : mode === "bottom"
4407
+ ? "flex-end"
4408
+ : "flex-start";
4409
+ onStyleChange(isColumnFlexText ? "justifyContent" : "alignItems", cssValue);
4123
4410
  };
4124
4411
 
4125
4412
  return (
@@ -4158,13 +4445,22 @@ function TypographyProperties({
4158
4445
  {/* Row 2: weight + size side by side */}
4159
4446
  <div className="grid grid-cols-2 gap-1.5">
4160
4447
  <Select
4161
- value={styles.fontWeight || "400"}
4448
+ value={fontWeightIsMixed ? MIXED_VALUE : styles.fontWeight || "400"}
4162
4449
  onValueChange={(v) => onStyleChange("fontWeight", v)}
4163
4450
  >
4164
4451
  <SelectTrigger className="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 !text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
4165
4452
  <SelectValue />
4166
4453
  </SelectTrigger>
4167
4454
  <SelectContent>
4455
+ {fontWeightIsMixed ? (
4456
+ <SelectItem
4457
+ value={MIXED_VALUE}
4458
+ disabled
4459
+ className="!text-[11px] text-muted-foreground"
4460
+ >
4461
+ {MIXED_VALUE}
4462
+ </SelectItem>
4463
+ ) : null}
4168
4464
  {fontWeightOptions.map((opt) => (
4169
4465
  <SelectItem
4170
4466
  key={opt.value}
@@ -4180,9 +4476,19 @@ function TypographyProperties({
4180
4476
  label={t("editPanel.labels.size")}
4181
4477
  ariaLabel={t("editPanel.labels.size")}
4182
4478
  icon={IconLetterCase}
4183
- value={styles.fontSize ? parseNumericValue(styles.fontSize) : 16}
4479
+ value={
4480
+ fontSizeIsMixed
4481
+ ? 0
4482
+ : styles.fontSize
4483
+ ? parseNumericValue(styles.fontSize)
4484
+ : 16
4485
+ }
4486
+ mixed={fontSizeIsMixed}
4184
4487
  onChange={(value) =>
4185
- onStyleChange("fontSize", `${Math.max(1, Math.round(value))}px`)
4488
+ onStyleChange(
4489
+ "fontSize",
4490
+ `${Math.max(1, roundToOneDecimal(value))}px`,
4491
+ )
4186
4492
  }
4187
4493
  unit="px"
4188
4494
  min={1}
@@ -4199,7 +4505,12 @@ function TypographyProperties({
4199
4505
  label={t("editPanel.labels.lineHeight")}
4200
4506
  ariaLabel={t("editPanel.labels.lineHeight")}
4201
4507
  icon={IconLineHeight}
4202
- value={resolveLineHeight(styles.lineHeight, styles.fontSize)}
4508
+ value={
4509
+ lineHeightIsMixed
4510
+ ? 0
4511
+ : resolveLineHeight(styles.lineHeight, styles.fontSize)
4512
+ }
4513
+ mixed={lineHeightIsMixed}
4203
4514
  onChange={(value) =>
4204
4515
  onStyleChange("lineHeight", String(Math.max(0.1, value)))
4205
4516
  }
@@ -4215,8 +4526,13 @@ function TypographyProperties({
4215
4526
  ariaLabel={t("editPanel.labels.tracking")}
4216
4527
  icon={IconLetterSpacing}
4217
4528
  value={
4218
- styles.letterSpacing ? parseNumericValue(styles.letterSpacing) : 0
4529
+ letterSpacingIsMixed
4530
+ ? 0
4531
+ : styles.letterSpacing
4532
+ ? parseNumericValue(styles.letterSpacing)
4533
+ : 0
4219
4534
  }
4535
+ mixed={letterSpacingIsMixed}
4220
4536
  onChange={(value) => onStyleChange("letterSpacing", `${value}px`)}
4221
4537
  unit="px"
4222
4538
  precision={1}
@@ -4801,6 +5117,23 @@ function ExportPreview({ element }: { element: ElementInfo | null }) {
4801
5117
  );
4802
5118
  }
4803
5119
 
5120
+ /**
5121
+ * Reads an authored (as-written) offset/size style, preferring the bridge's
5122
+ * `inlineStyles` capture (raw `el.style.<prop>` — "auto"/"" reliably means
5123
+ * unset) over `computedStyles` (which always resolves to a px value for
5124
+ * absolutely-positioned elements even when the author never set the
5125
+ * property, making "unset" indistinguishable from "0px"). Falls back to the
5126
+ * computed value for older payloads that predate `inlineStyles`.
5127
+ */
5128
+ export function authoredStyleValue(
5129
+ element: ElementInfo,
5130
+ property: string,
5131
+ ): string | undefined {
5132
+ const inline = element.inlineStyles?.[property];
5133
+ if (inline !== undefined) return inline === "auto" ? "" : inline;
5134
+ return element.computedStyles[property];
5135
+ }
5136
+
4804
5137
  /** Position, size, and spacing properties */
4805
5138
  function PositionLayoutProperties({
4806
5139
  element,
@@ -4813,31 +5146,67 @@ function PositionLayoutProperties({
4813
5146
  const styles = element.computedStyles;
4814
5147
  const constrainedPosition =
4815
5148
  styles.position === "absolute" || styles.position === "fixed";
4816
- // Reflect the active packing in the alignment segments (the design editor highlights the
4817
- // current alignment). For a flex container the main axis is justifyContent.
4818
- const alignH = justifyToHorizontal(styles.justifyContent);
4819
- const alignV = alignToVertical(styles.alignItems);
5149
+ // Reflect the active packing in the alignment segments (the design editor
5150
+ // highlights the current alignment) these buttons align the selected
5151
+ // element's own children, the same operation FlexContainerControls' full
5152
+ // AutoLayoutMatrix alignment grid performs. A column-direction flex
5153
+ // container swaps which CSS axis is "horizontal"/"vertical" from the
5154
+ // user's point of view: justifyContent packs the main (here: vertical)
5155
+ // axis and alignItems packs the cross (horizontal) axis, so reading/
5156
+ // writing must go through autoLayoutAlignmentFromStyles /
5157
+ // horizontalToJustify / verticalToAlign like the matrix control does,
5158
+ // instead of assuming justifyContent is always horizontal.
5159
+ const positionFlexDirection: AutoLayoutMatrixValue["direction"] =
5160
+ styles.flexDirection?.includes("column") ? "vertical" : "horizontal";
5161
+ const positionAlignment = autoLayoutAlignmentFromStyles(
5162
+ styles,
5163
+ positionFlexDirection,
5164
+ );
5165
+ const alignH = positionAlignment.horizontal;
5166
+ const alignV = positionAlignment.vertical;
5167
+ const handlePositionAlignH = (value: AlignmentMatrixValue["horizontal"]) => {
5168
+ onStyleChange(
5169
+ positionFlexDirection === "vertical" ? "alignItems" : "justifyContent",
5170
+ horizontalToJustify(value),
5171
+ );
5172
+ };
5173
+ const handlePositionAlignV = (value: AlignmentMatrixValue["vertical"]) => {
5174
+ onStyleChange(
5175
+ positionFlexDirection === "vertical" ? "justifyContent" : "alignItems",
5176
+ verticalToAlign(value),
5177
+ );
5178
+ };
5179
+ // Authored (not computed) offsets: when inlineStyles is present, "auto"/absent
5180
+ // is treated as truly unset instead of the computed px fallback, so a plain
5181
+ // top-left-anchored element reads as "left" rather than always "left-right".
5182
+ const authoredLeft = authoredStyleValue(element, "left");
5183
+ const authoredRight = authoredStyleValue(element, "right");
5184
+ const authoredTop = authoredStyleValue(element, "top");
5185
+ const authoredBottom = authoredStyleValue(element, "bottom");
5186
+ const authoredWidth = authoredStyleValue(element, "width");
5187
+ const authoredHeight = authoredStyleValue(element, "height");
5188
+ const authoredTransform = authoredStyleValue(element, "transform");
4820
5189
  const constraintsValue: ConstraintsValue = {
4821
5190
  horizontal:
4822
5191
  // Check scale before left+right: "scale" writes width:100% and clears
4823
5192
  // left/right to auto, but legacy data may have 0px values that are truthy.
4824
- styles.width === "100%"
5193
+ authoredWidth === "100%"
4825
5194
  ? "scale"
4826
- : styles.left && styles.right
5195
+ : authoredLeft && authoredRight
4827
5196
  ? "left-right"
4828
- : styles.right
5197
+ : authoredRight
4829
5198
  ? "right"
4830
- : styles.transform?.includes("translateX(-50%)")
5199
+ : authoredTransform?.includes("translateX(-50%)")
4831
5200
  ? "center"
4832
5201
  : "left",
4833
5202
  vertical:
4834
- styles.height === "100%"
5203
+ authoredHeight === "100%"
4835
5204
  ? "scale"
4836
- : styles.top && styles.bottom
5205
+ : authoredTop && authoredBottom
4837
5206
  ? "top-bottom"
4838
- : styles.bottom
5207
+ : authoredBottom
4839
5208
  ? "bottom"
4840
- : styles.transform?.includes("translateY(-50%)")
5209
+ : authoredTransform?.includes("translateY(-50%)")
4841
5210
  ? "center"
4842
5211
  : "top",
4843
5212
  };
@@ -4854,7 +5223,7 @@ function PositionLayoutProperties({
4854
5223
  const tyValue = value.vertical === "center" ? "-50%" : null;
4855
5224
  // Start from the current transform, apply X, then apply Y on top.
4856
5225
  const transformAfterX = mergeTranslateFunction(
4857
- styles.transform,
5226
+ authoredTransform,
4858
5227
  "X",
4859
5228
  txValue,
4860
5229
  );
@@ -4867,7 +5236,7 @@ function PositionLayoutProperties({
4867
5236
  if (value.horizontal === "left") {
4868
5237
  onStyleChange(
4869
5238
  "left",
4870
- styles.left || `${Math.round(element.boundingRect.x)}px`,
5239
+ authoredLeft || `${Math.round(element.boundingRect.x)}px`,
4871
5240
  );
4872
5241
  onStyleChange("right", "auto");
4873
5242
  } else if (value.horizontal === "right") {
@@ -4876,7 +5245,7 @@ function PositionLayoutProperties({
4876
5245
  } else if (value.horizontal === "left-right") {
4877
5246
  onStyleChange(
4878
5247
  "left",
4879
- styles.left || `${Math.round(element.boundingRect.x)}px`,
5248
+ authoredLeft || `${Math.round(element.boundingRect.x)}px`,
4880
5249
  );
4881
5250
  onStyleChange("right", "0px");
4882
5251
  } else if (value.horizontal === "center") {
@@ -4893,7 +5262,7 @@ function PositionLayoutProperties({
4893
5262
  if (value.vertical === "top") {
4894
5263
  onStyleChange(
4895
5264
  "top",
4896
- styles.top || `${Math.round(element.boundingRect.y)}px`,
5265
+ authoredTop || `${Math.round(element.boundingRect.y)}px`,
4897
5266
  );
4898
5267
  onStyleChange("bottom", "auto");
4899
5268
  } else if (value.vertical === "bottom") {
@@ -4902,7 +5271,7 @@ function PositionLayoutProperties({
4902
5271
  } else if (value.vertical === "top-bottom") {
4903
5272
  onStyleChange(
4904
5273
  "top",
4905
- styles.top || `${Math.round(element.boundingRect.y)}px`,
5274
+ authoredTop || `${Math.round(element.boundingRect.y)}px`,
4906
5275
  );
4907
5276
  onStyleChange("bottom", "0px");
4908
5277
  } else if (value.vertical === "center") {
@@ -4922,9 +5291,9 @@ function PositionLayoutProperties({
4922
5291
  element.boundingRect.x,
4923
5292
  element.boundingRect.y,
4924
5293
  onStyleChange,
4925
- styles.left,
4926
- styles.top,
4927
- styles.transform,
5294
+ authoredLeft,
5295
+ authoredTop,
5296
+ authoredTransform,
4928
5297
  ],
4929
5298
  );
4930
5299
 
@@ -4955,21 +5324,21 @@ function PositionLayoutProperties({
4955
5324
  <InspectorIconButton
4956
5325
  label={t("editPanel.textAligns.left")}
4957
5326
  active={alignH === "left"}
4958
- onClick={() => onStyleChange("justifyContent", "flex-start")}
5327
+ onClick={() => handlePositionAlignH("left")}
4959
5328
  >
4960
5329
  <IconLayoutAlignLeft className="size-3.5" />
4961
5330
  </InspectorIconButton>
4962
5331
  <InspectorIconButton
4963
5332
  label={t("editPanel.textAligns.center")}
4964
5333
  active={alignH === "center"}
4965
- onClick={() => onStyleChange("justifyContent", "center")}
5334
+ onClick={() => handlePositionAlignH("center")}
4966
5335
  >
4967
5336
  <IconLayoutAlignCenter className="size-3.5" />
4968
5337
  </InspectorIconButton>
4969
5338
  <InspectorIconButton
4970
5339
  label={t("editPanel.textAligns.right")}
4971
5340
  active={alignH === "right"}
4972
- onClick={() => onStyleChange("justifyContent", "flex-end")}
5341
+ onClick={() => handlePositionAlignH("right")}
4973
5342
  >
4974
5343
  <IconLayoutAlignRight className="size-3.5" />
4975
5344
  </InspectorIconButton>
@@ -4978,21 +5347,21 @@ function PositionLayoutProperties({
4978
5347
  <InspectorIconButton
4979
5348
  label={t("editPanel.alignSelfOptions.start")}
4980
5349
  active={alignV === "top"}
4981
- onClick={() => onStyleChange("alignItems", "flex-start")}
5350
+ onClick={() => handlePositionAlignV("top")}
4982
5351
  >
4983
5352
  <IconLayoutAlignTop className="size-3.5" />
4984
5353
  </InspectorIconButton>
4985
5354
  <InspectorIconButton
4986
5355
  label={t("editPanel.alignSelfOptions.center")}
4987
5356
  active={alignV === "middle"}
4988
- onClick={() => onStyleChange("alignItems", "center")}
5357
+ onClick={() => handlePositionAlignV("middle")}
4989
5358
  >
4990
5359
  <IconLayoutAlignMiddle className="size-3.5" />
4991
5360
  </InspectorIconButton>
4992
5361
  <InspectorIconButton
4993
5362
  label={t("editPanel.alignSelfOptions.end")}
4994
5363
  active={alignV === "bottom"}
4995
- onClick={() => onStyleChange("alignItems", "flex-end")}
5364
+ onClick={() => handlePositionAlignV("bottom")}
4996
5365
  >
4997
5366
  <IconLayoutAlignBottom className="size-3.5" />
4998
5367
  </InspectorIconButton>
@@ -5007,19 +5376,32 @@ function PositionLayoutProperties({
5007
5376
  label="X"
5008
5377
  ariaLabel="X-position"
5009
5378
  tooltipLabel="X-position"
5010
- value={styles.left || ""}
5379
+ value={
5380
+ isMixedValue(authoredLeft) ? MIXED_VALUE : authoredLeft || ""
5381
+ }
5011
5382
  placeholder={element.boundingRect.x}
5012
5383
  inputClassName="h-6"
5013
- onChange={(v) => onStyleChange("left", `${Math.round(v)}px`)}
5384
+ onChange={(v) => {
5385
+ // Typing X/Y on a static (non-positioned) element is a no-op on
5386
+ // canvas unless we first give it a position to offset from —
5387
+ // mirror handleConstraintsChange, which always sets
5388
+ // position:absolute (the convention canvas drag/resize and
5389
+ // primitive creation both use) before writing left/top.
5390
+ if (!constrainedPosition) onStyleChange("position", "absolute");
5391
+ onStyleChange("left", `${Math.round(v)}px`);
5392
+ }}
5014
5393
  />
5015
5394
  <ScrubStyleInput
5016
5395
  label="Y"
5017
5396
  ariaLabel="Y-position"
5018
5397
  tooltipLabel="Y-position"
5019
- value={styles.top || ""}
5398
+ value={isMixedValue(authoredTop) ? MIXED_VALUE : authoredTop || ""}
5020
5399
  placeholder={element.boundingRect.y}
5021
5400
  inputClassName="h-6"
5022
- onChange={(v) => onStyleChange("top", `${Math.round(v)}px`)}
5401
+ onChange={(v) => {
5402
+ if (!constrainedPosition) onStyleChange("position", "absolute");
5403
+ onStyleChange("top", `${Math.round(v)}px`);
5404
+ }}
5023
5405
  />
5024
5406
  <Tooltip>
5025
5407
  <TooltipTrigger asChild>
@@ -5067,13 +5449,29 @@ function PositionLayoutProperties({
5067
5449
  hideIcon={false}
5068
5450
  icon={IconAngle}
5069
5451
  labelClassName="[&>span]:sr-only"
5070
- value={`${parseRotationValue(styles.transform)}deg`}
5452
+ // Detect the Mixed sentinel BEFORE parsing: parseRotationValue
5453
+ // would silently turn "Mixed" into 0 and render "0deg" instead
5454
+ // of the mixed state (mirrors the opacity field's guard).
5455
+ value={
5456
+ isMixedValue(styles.transform)
5457
+ ? MIXED_VALUE
5458
+ : `${parseRotationValue(styles.transform)}deg`
5459
+ }
5071
5460
  unit="deg"
5072
5461
  inputClassName="h-6"
5073
5462
  onChange={(v) =>
5074
5463
  onStyleChange(
5075
5464
  "transform",
5076
- mergeRotationValue(styles.transform, v),
5465
+ // From a mixed selection the sentinel is not a transform
5466
+ // treat it as absent so the typed value applies cleanly to
5467
+ // every selected object instead of producing
5468
+ // "Mixed rotate(…)".
5469
+ mergeRotationValue(
5470
+ isMixedValue(styles.transform)
5471
+ ? undefined
5472
+ : styles.transform,
5473
+ v,
5474
+ ),
5077
5475
  )
5078
5476
  }
5079
5477
  />
@@ -5115,21 +5513,21 @@ function FillProperties({
5115
5513
  }) {
5116
5514
  const t = useT();
5117
5515
  const styles = element.computedStyles;
5118
- const isTextElement = TEXT_TAGS.has(element.tagName);
5119
- const fillProperty = isTextElement ? "color" : "backgroundColor";
5120
- const fillValue = isTextElement
5516
+ const isTextFillElement = isTextElement(element);
5517
+ const fillProperty = isTextFillElement ? "color" : "backgroundColor";
5518
+ const fillValue = isTextFillElement
5121
5519
  ? styles.color || ""
5122
5520
  : styles.backgroundColor || "";
5123
- const backgroundLayers = isTextElement
5521
+ const backgroundLayers = isTextFillElement
5124
5522
  ? []
5125
5523
  : splitCssLayers(styles.backgroundImage || "");
5126
- const backgroundSizeLayers = isTextElement
5524
+ const backgroundSizeLayers = isTextFillElement
5127
5525
  ? []
5128
5526
  : splitCssLayers(styles.backgroundSize || "");
5129
- const backgroundRepeatLayers = isTextElement
5527
+ const backgroundRepeatLayers = isTextFillElement
5130
5528
  ? []
5131
5529
  : splitCssLayers(styles.backgroundRepeat || "");
5132
- const backgroundPositionLayers = isTextElement
5530
+ const backgroundPositionLayers = isTextFillElement
5133
5531
  ? []
5134
5532
  : splitCssLayers(styles.backgroundPosition || "");
5135
5533
  const fillIsMixed =
@@ -5138,39 +5536,36 @@ function FillProperties({
5138
5536
  isMixedValue(styles.backgroundSize) ||
5139
5537
  isMixedValue(styles.backgroundRepeat) ||
5140
5538
  isMixedValue(styles.backgroundPosition);
5141
- const hasBackgroundLayer = !isTextElement && backgroundLayers.length > 0;
5539
+ const hasBackgroundLayer = !isTextFillElement && backgroundLayers.length > 0;
5142
5540
  const hasVisibleFill =
5143
- isTextElement || colorHasVisibleAlpha(fillValue) || hasBackgroundLayer;
5144
-
5145
- // Non-destructive fill hide: stash the color before hiding so toggling
5146
- // visible again restores the exact original value (the design editor never loses color).
5147
- // Keyed by a stable selected-element identity so anonymous same-tag elements
5148
- // don't share stash slots.
5149
- const [hiddenFillStash, setHiddenFillStash] = useState<
5150
- Record<string, string>
5151
- >({});
5152
- // Same non-destructive idea for background gradient/image layers: stash the
5153
- // exact layer string on hide so per-stop opacity survives a hide→show toggle
5154
- // instead of being flattened to all-0 then all-100.
5155
- const [hiddenLayerStash, setHiddenLayerStash] = useState<
5156
- Record<string, string>
5157
- >({});
5158
- const stashKey = `${elementIdentityKey(element)}:${fillProperty}`;
5541
+ isTextFillElement || colorHasVisibleAlpha(fillValue) || hasBackgroundLayer;
5542
+
5543
+ // Non-destructive fill hide: instead of stashing the pre-hide color in
5544
+ // React state (lost on unmount e.g. deselect then reselect the same
5545
+ // element, since FillProperties only mounts while something is selected),
5546
+ // zero the alpha channel while preserving the RGB channels in the
5547
+ // persisted CSS itself: rgba(r,g,b,0) renders identically to fully
5548
+ // transparent, but getComputedStyle round-trips the r/g/b losslessly (CSS
5549
+ // color-list channels are real data, unlike comments — verified
5550
+ // separately: computed style strips comments but keeps rgba() channels).
5551
+ // Showing again just restores alpha to 1 using those same channels, so no
5552
+ // stash is required and the hide survives reselect/reload.
5159
5553
  const isHidden = !colorHasVisibleAlpha(fillValue);
5160
5554
  const handleFillVisibilityToggle = () => {
5555
+ const parsed = parseCssColor(fillValue);
5161
5556
  if (isHidden) {
5162
- // Restore the stashed color, or fall back to a sensible default.
5163
- const restored =
5164
- hiddenFillStash[stashKey] ?? (isTextElement ? "#000000" : "#ffffff");
5557
+ const restored = parsed
5558
+ ? rgbaToCss(withColorOpacity(parsed, 100))
5559
+ : isTextFillElement
5560
+ ? "#000000"
5561
+ : "#ffffff";
5165
5562
  onStyleChange(fillProperty, restored);
5166
- setHiddenFillStash((prev) => {
5167
- const next = { ...prev };
5168
- delete next[stashKey];
5169
- return next;
5170
- });
5563
+ } else if (parsed) {
5564
+ onStyleChange(fillProperty, rgbaToCss(withColorOpacity(parsed, 0)));
5171
5565
  } else {
5172
- // Stash the current color before going transparent.
5173
- setHiddenFillStash((prev) => ({ ...prev, [stashKey]: fillValue }));
5566
+ // Value wasn't a parseable color (e.g. already the literal
5567
+ // "transparent") nothing to preserve, transparent is the best we
5568
+ // can do.
5174
5569
  onStyleChange(fillProperty, "transparent");
5175
5570
  }
5176
5571
  };
@@ -5219,7 +5614,7 @@ function FillProperties({
5219
5614
  );
5220
5615
  return;
5221
5616
  }
5222
- if (isTextElement) {
5617
+ if (isTextFillElement) {
5223
5618
  onStyleChange(
5224
5619
  "color",
5225
5620
  cssColorOrFallback(styles.color, "#000000"),
@@ -5257,7 +5652,7 @@ function FillProperties({
5257
5652
  </p>
5258
5653
  ) : hasVisibleFill ? (
5259
5654
  <div className="space-y-1.5">
5260
- {isTextElement || colorHasVisibleAlpha(fillValue) ? (
5655
+ {isTextFillElement || colorHasVisibleAlpha(fillValue) ? (
5261
5656
  /* design row: [swatch+hex trigger (flex-1)] [eye] [remove] */
5262
5657
  <div className="group flex items-center gap-1.5">
5263
5658
  <div className="min-w-0 flex-1">
@@ -5265,17 +5660,51 @@ function FillProperties({
5265
5660
  label=""
5266
5661
  value={fillValue}
5267
5662
  onChange={(v) => onStyleChange(fillProperty, v)}
5268
- backgroundImage=""
5663
+ // Pass the real layer stack (not "") so that switching this
5664
+ // swatch's paint type to gradient/image composes a new
5665
+ // layer on top of any existing backgroundImage layers
5666
+ // (rendered as their own rows below) instead of clobbering
5667
+ // them — ColorInput derives its add/replace-layer logic
5668
+ // from this prop.
5669
+ backgroundImage={
5670
+ isTextFillElement ? "" : styles.backgroundImage
5671
+ }
5269
5672
  blendMode={
5270
- isTextElement
5673
+ isTextFillElement
5271
5674
  ? undefined
5272
5675
  : styles.backgroundBlendMode || "normal"
5273
5676
  }
5274
5677
  onBlendModeChange={
5275
- isTextElement
5678
+ isTextFillElement
5276
5679
  ? undefined
5277
5680
  : (v) => onStyleChange("backgroundBlendMode", v)
5278
5681
  }
5682
+ // Text fill ("color") can't hold a gradient/image paint —
5683
+ // there is no background-clip:text support here — so never
5684
+ // offer layered fills for it; the picker's Gradient/Image
5685
+ // tabs remain reachable as raw UI (it manages that tab
5686
+ // selection as local state independent of these props) but
5687
+ // ColorInput's setNext guard rejects non-color writes back
5688
+ // into `color` when supportsLayeredFills is false. For any
5689
+ // other element the base fill is a real backgroundImage
5690
+ // layer stack, so wire the same layered-fill handlers the
5691
+ // page background row uses (see PageProperties above).
5692
+ supportsLayeredFills={!isTextFillElement}
5693
+ onBackgroundImageChange={
5694
+ isTextFillElement
5695
+ ? undefined
5696
+ : (v) => onStyleChange("backgroundImage", v)
5697
+ }
5698
+ onImageFillChange={
5699
+ isTextFillElement
5700
+ ? undefined
5701
+ : (value) =>
5702
+ commitStylePatch(
5703
+ imageFillToBackgroundStyles(value),
5704
+ onStyleChange,
5705
+ onStylesChange,
5706
+ )
5707
+ }
5279
5708
  documentColors={documentColors}
5280
5709
  pickerKey={[
5281
5710
  element.sourceId ??
@@ -5304,7 +5733,7 @@ function FillProperties({
5304
5733
  <SectionIconButton
5305
5734
  label={t("editPanel.labels.removeLayer")}
5306
5735
  onClick={() => {
5307
- if (isTextElement) {
5736
+ if (isTextFillElement) {
5308
5737
  onStyleChange(fillProperty, "transparent");
5309
5738
  return;
5310
5739
  }
@@ -5322,23 +5751,21 @@ function FillProperties({
5322
5751
  </SectionIconButton>
5323
5752
  </div>
5324
5753
  ) : null}
5325
- {!isTextElement
5754
+ {!isTextFillElement
5326
5755
  ? backgroundLayers.map((layer, index) => {
5327
5756
  const gradient = parseGradientLayer(layer);
5757
+ // Hidden state lives in the real, persisted backgroundSize
5758
+ // marker (see withLayerSizeMarker) rather than React state,
5759
+ // so it survives deselect/reselect. Opacity still reflects
5760
+ // the gradient's own stop opacities for display, but no
5761
+ // longer drives hide/show — a layer can be a fully-opaque
5762
+ // gradient and still be hidden via zero-size.
5763
+ const hidden = isLayerHiddenBySize(backgroundSizeLayers[index]);
5328
5764
  const opacity = gradient
5329
5765
  ? averageGradientOpacity(gradient.stops)
5330
5766
  : 100;
5331
- const layerStashKey = `${elementIdentityKey(element)}:layer:${index}`;
5332
- const stashedLayer = hiddenLayerStash[layerStashKey];
5333
- const hiddenImagePlaceholder = Boolean(
5334
- stashedLayer && gradient && opacity <= 0,
5335
- );
5336
5767
  const label = gradient
5337
- ? hiddenImagePlaceholder
5338
- ? `${"Image" /* i18n-ignore design inspector paint row */} ${
5339
- index + 1
5340
- }`
5341
- : `${gradientLabel(gradient.type)} ${index + 1}`
5768
+ ? `${gradientLabel(gradient.type)} ${index + 1}`
5342
5769
  : `${"Image" /* i18n-ignore design inspector paint row */} ${
5343
5770
  index + 1
5344
5771
  }`;
@@ -5356,6 +5783,25 @@ function FillProperties({
5356
5783
  ),
5357
5784
  ),
5358
5785
  );
5786
+ onStyleChange(
5787
+ "backgroundSize",
5788
+ joinCssLayers(
5789
+ backgroundSizeLayers.filter(
5790
+ (_, layerIndex) => layerIndex !== index,
5791
+ ),
5792
+ ),
5793
+ );
5794
+ };
5795
+ const setLayerHidden = (nextHidden: boolean) => {
5796
+ onStyleChange(
5797
+ "backgroundSize",
5798
+ withLayerSizeMarker(
5799
+ backgroundSizeLayers,
5800
+ backgroundLayers.length,
5801
+ index,
5802
+ nextHidden,
5803
+ ),
5804
+ );
5359
5805
  };
5360
5806
 
5361
5807
  return (
@@ -5378,7 +5824,7 @@ function FillProperties({
5378
5824
  {label}
5379
5825
  </span>
5380
5826
  <span className="shrink-0 tabular-nums text-muted-foreground">
5381
- {opacity}%
5827
+ {hidden ? 0 : opacity}%
5382
5828
  </span>
5383
5829
  </button>
5384
5830
  </PopoverTrigger>
@@ -5389,7 +5835,7 @@ function FillProperties({
5389
5835
  className="w-80 p-0"
5390
5836
  >
5391
5837
  <DesignColorPicker
5392
- value={gradient?.stops[0]?.color ?? layer}
5838
+ value={layer}
5393
5839
  onPaintValueChange={replaceLayer}
5394
5840
  onChange={(nextColor) => {
5395
5841
  if (!gradient) return;
@@ -5434,78 +5880,14 @@ function FillProperties({
5434
5880
  </Popover>
5435
5881
  <SectionIconButton
5436
5882
  label={
5437
- opacity <= 0
5883
+ hidden
5438
5884
  ? t("editPanel.labels.showLayer")
5439
5885
  : t("editPanel.labels.hideLayer")
5440
5886
  }
5441
- onClick={() => {
5442
- if (opacity <= 0) {
5443
- // Show: restore the exact pre-hide layer if stashed,
5444
- // otherwise fall back to forcing every stop opaque.
5445
- const stashed = hiddenLayerStash[layerStashKey];
5446
- if (stashed !== undefined) {
5447
- replaceLayer(stashed);
5448
- setHiddenLayerStash((prev) => {
5449
- const next = { ...prev };
5450
- delete next[layerStashKey];
5451
- return next;
5452
- });
5453
- } else if (gradient) {
5454
- replaceLayer(
5455
- buildGradientLayer(
5456
- gradient.type,
5457
- gradient.stops.map((stop) => ({
5458
- ...stop,
5459
- opacity: 100,
5460
- })),
5461
- gradient.prefix,
5462
- ),
5463
- );
5464
- }
5465
- return;
5466
- }
5467
- if (!gradient) {
5468
- setHiddenLayerStash((prev) => ({
5469
- ...prev,
5470
- [layerStashKey]: layer,
5471
- }));
5472
- replaceLayer(
5473
- buildGradientLayer("linear", [
5474
- {
5475
- id: "stop-0",
5476
- color: "rgba(0, 0, 0, 0)",
5477
- position: 0,
5478
- opacity: 0,
5479
- },
5480
- {
5481
- id: "stop-1",
5482
- color: "rgba(0, 0, 0, 0)",
5483
- position: 100,
5484
- opacity: 0,
5485
- },
5486
- ]),
5487
- );
5488
- } else {
5489
- // Hide: stash the current layer (with its real per-stop
5490
- // opacities) before zeroing every stop's alpha.
5491
- setHiddenLayerStash((prev) => ({
5492
- ...prev,
5493
- [layerStashKey]: layer,
5494
- }));
5495
- replaceLayer(
5496
- buildGradientLayer(
5497
- gradient.type,
5498
- gradient.stops.map((stop) => ({
5499
- ...stop,
5500
- opacity: 0,
5501
- })),
5502
- gradient.prefix,
5503
- ),
5504
- );
5505
- }
5506
- }}
5887
+ onClick={() => setLayerHidden(!hidden)}
5888
+ activateOnPointerDown
5507
5889
  >
5508
- {opacity <= 0 ? (
5890
+ {hidden ? (
5509
5891
  <IconEyeOff className="size-3.5" />
5510
5892
  ) : (
5511
5893
  <IconEye className="size-3.5" />
@@ -5538,11 +5920,14 @@ function StrokeProperties({
5538
5920
  }) {
5539
5921
  const t = useT();
5540
5922
  const styles = element.computedStyles;
5541
- const borderVisible = strokeIsVisible(styles.borderWidth, styles.borderStyle);
5542
- const outlineVisible = strokeIsVisible(
5543
- styles.outlineWidth,
5544
- styles.outlineStyle,
5545
- );
5923
+ // Visible requires: real width, style not "none" (legacy hide path), and
5924
+ // color not zero-alpha (current hide path — see strokeHiddenByColor).
5925
+ const borderVisible =
5926
+ strokeIsVisible(styles.borderWidth, styles.borderStyle) &&
5927
+ !strokeHiddenByColor(styles.borderColor);
5928
+ const outlineVisible =
5929
+ strokeIsVisible(styles.outlineWidth, styles.outlineStyle) &&
5930
+ !strokeHiddenByColor(styles.outlineColor);
5546
5931
  const strokeIsMixed = [
5547
5932
  styles.borderWidth,
5548
5933
  styles.borderStyle,
@@ -5580,8 +5965,17 @@ function StrokeProperties({
5580
5965
  return;
5581
5966
  }
5582
5967
  if (!borderVisible) {
5968
+ // Restore full alpha before falling back to cssColorOrFallback
5969
+ // — a border previously hidden via the eye toggle (zero-alpha,
5970
+ // real RGB preserved) is not "transparent" by that helper's
5971
+ // narrow literal check, so without this an "Add" click here
5972
+ // could silently re-add an invisible border.
5973
+ const existingBorderColor = styles.borderColor || styles.color;
5974
+ const existingParsed = parseCssColor(existingBorderColor || "");
5583
5975
  const borderColor = cssColorOrFallback(
5584
- styles.borderColor || styles.color,
5976
+ existingParsed
5977
+ ? rgbaToCss(withColorOpacity(existingParsed, 100))
5978
+ : existingBorderColor,
5585
5979
  "#000000",
5586
5980
  );
5587
5981
  commitStylePatch(
@@ -5742,7 +6136,15 @@ function AppearanceProperties({
5742
6136
  unit="%"
5743
6137
  precision={1}
5744
6138
  />
5745
- <CornerRadiusControl styles={styles} onStyleChange={onStyleChange} />
6139
+ {/* Selection-stable key so per-selection UI state (the independent-
6140
+ corners toggle, which ratchets open while corners differ) resets on
6141
+ selection change instead of leaking to the next element — same
6142
+ pattern as ExportSettingsPanel. */}
6143
+ <CornerRadiusControl
6144
+ key={elementIdentityKey(element)}
6145
+ styles={styles}
6146
+ onStyleChange={onStyleChange}
6147
+ />
5746
6148
  </div>
5747
6149
  </PanelSection>
5748
6150
  );
@@ -6928,7 +7330,18 @@ export function ComponentSection({
6928
7330
  );
6929
7331
  }
6930
7332
 
6931
- export function EditPanel({
7333
+ // PF8: EditPanel re-renders on every DesignEditor state change (drag,
7334
+ // hover, zoom) unless memoized. Nearly all props are already stabilized at
7335
+ // the call site (useMemo/useCallback — see DesignEditor.tsx's
7336
+ // selectedInspectorElements/selectedScreenGeometry/pageStyles/tweaks/
7337
+ // sourceCapabilities/statesPanelProps/reviewPanelProps and the onXxx
7338
+ // handlers passed to <EditPanel>). `headerTrailing` and `aiActions` are
7339
+ // legitimately-dynamic ReactNode slots (the zoom control repaints its own
7340
+ // live percentage every zoom tick; aiActions depends on the live
7341
+ // selection) — their identity is expected to change often and a custom
7342
+ // comparator that ignored them would hide real content changes, so this
7343
+ // uses the default shallow comparison rather than special-casing them.
7344
+ export const EditPanel = memo(function EditPanel({
6932
7345
  selectedElement,
6933
7346
  selectedElements,
6934
7347
  selectedScreenGeometry,
@@ -7344,4 +7757,4 @@ export function EditPanel({
7344
7757
  ) : null}
7345
7758
  </div>
7346
7759
  );
7347
- }
7760
+ });