@1agh/maude 0.40.0 → 0.41.0

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 (73) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -3
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/annotate.mjs +576 -34
  9. package/apps/studio/bin/canvas-rects.sh +152 -0
  10. package/apps/studio/bin/fetch-asset.sh +34 -0
  11. package/apps/studio/bin/read-annotations.mjs +138 -7
  12. package/apps/studio/bin/smoke.sh +42 -6
  13. package/apps/studio/build.ts +21 -0
  14. package/apps/studio/canvas-comment-mount.tsx +138 -4
  15. package/apps/studio/canvas-edit.ts +744 -11
  16. package/apps/studio/canvas-lib.tsx +219 -2
  17. package/apps/studio/canvas-shell.tsx +487 -20
  18. package/apps/studio/client/app.jsx +1451 -22
  19. package/apps/studio/client/comments-overlay.css +130 -126
  20. package/apps/studio/client/github.js +8 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +48 -0
  22. package/apps/studio/comments-overlay.tsx +148 -41
  23. package/apps/studio/context-menu.tsx +15 -5
  24. package/apps/studio/contextual-toolbar.tsx +262 -4
  25. package/apps/studio/cursors-overlay.tsx +4 -4
  26. package/apps/studio/dist/client.bundle.js +20 -20
  27. package/apps/studio/dist/comment-mount.js +59 -1
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/dom-selection.ts +127 -1
  30. package/apps/studio/drag-state.ts +24 -0
  31. package/apps/studio/equal-spacing-detector.ts +205 -0
  32. package/apps/studio/export-dialog.tsx +1 -1
  33. package/apps/studio/history.ts +47 -1
  34. package/apps/studio/http.ts +223 -0
  35. package/apps/studio/input-router.tsx +12 -0
  36. package/apps/studio/marquee-overlay.tsx +1 -1
  37. package/apps/studio/measure-overlay.tsx +241 -0
  38. package/apps/studio/participants-chrome.tsx +3 -3
  39. package/apps/studio/sizing-mode.ts +117 -0
  40. package/apps/studio/spacing-handles.ts +166 -0
  41. package/apps/studio/test/annotate-write.test.ts +890 -0
  42. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  43. package/apps/studio/test/canvas-edit.test.ts +50 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
  45. package/apps/studio/test/canvas-rects.test.ts +198 -0
  46. package/apps/studio/test/comments-overlay.test.ts +117 -0
  47. package/apps/studio/test/dom-selection.test.ts +130 -0
  48. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  50. package/apps/studio/test/element-resize.test.ts +136 -0
  51. package/apps/studio/test/element-structural-api.test.ts +360 -0
  52. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  53. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  54. package/apps/studio/test/history-rollback.test.ts +26 -0
  55. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  56. package/apps/studio/test/read-annotations.test.ts +154 -0
  57. package/apps/studio/test/sizing-mode.test.ts +102 -0
  58. package/apps/studio/test/spacing-handles.test.ts +138 -0
  59. package/apps/studio/test/specimen-select.test.ts +88 -0
  60. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  61. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  62. package/apps/studio/tool-palette.tsx +122 -2
  63. package/apps/studio/undo-hud.tsx +2 -2
  64. package/apps/studio/use-element-resize.tsx +732 -0
  65. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  66. package/apps/studio/use-selection-set.tsx +14 -0
  67. package/apps/studio/use-spacing-handles.tsx +388 -0
  68. package/apps/studio/whats-new.json +28 -0
  69. package/cli/commands/design.mjs +6 -1
  70. package/cli/commands/design.test.mjs +49 -1
  71. package/cli/lib/fetch-asset.test.mjs +213 -0
  72. package/package.json +8 -8
  73. package/plugins/design/dependencies.json +10 -2
@@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client';
12
12
  // import that Bun erases), so this pulls only string constants into the client
13
13
  // bundle — no React, no input-router. See the tool-cursor handler below.
14
14
  import { resolveToolCursor } from '../canvas-cursors.ts';
15
+ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
15
16
  import { canvasUrl } from './canvas-url.js';
16
17
  import ChatPanel from './panels/ChatPanel.jsx';
17
18
  import DiffView from './panels/DiffView.jsx';
@@ -26,6 +27,7 @@ import {
26
27
  appIsFirstRun,
27
28
  isNativeApp,
28
29
  onUpdateReady,
30
+ pickMediaFile,
29
31
  restartToUpdate,
30
32
  saveExport,
31
33
  } from './github.js';
@@ -839,6 +841,167 @@ const PNG_SCALES = [
839
841
  { value: 3, label: '3× (max)' },
840
842
  ];
841
843
 
844
+ // feature-element-editing-robustness Stage F1 — AssetPicker. A shell modal that
845
+ // lists the versioned content-addressed media under <designRoot>/assets/ (via the
846
+ // main-origin-only GET /_api/assets) and lets the user pick one — or upload a new
847
+ // file (POST /_api/asset, content-addressed) — for a media Replace / image
848
+ // Insert. Thumbnails load from the main origin's designRoot static serve
849
+ // (/${designRel}/${asset.path}); never a remote hotlink (the CSP split origin
850
+ // blocks those — memory reference_canvas_images_download_first).
851
+ function AssetPicker({ designRel, onPick, onClose }) {
852
+ const [assets, setAssets] = useState(null);
853
+ const [busy, setBusy] = useState(false);
854
+ const [err, setErr] = useState(null);
855
+
856
+ useEffect(() => {
857
+ let alive = true;
858
+ fetch('/_api/assets')
859
+ .then((r) => r.json())
860
+ .then((j) => alive && setAssets(j.ok ? j.assets : []))
861
+ .catch(() => alive && setAssets([]));
862
+ return () => {
863
+ alive = false;
864
+ };
865
+ }, []);
866
+
867
+ useEffect(() => {
868
+ const onKey = (e) => {
869
+ if (e.key === 'Escape') {
870
+ e.stopPropagation();
871
+ onClose();
872
+ }
873
+ };
874
+ window.addEventListener('keydown', onKey, true);
875
+ return () => window.removeEventListener('keydown', onKey, true);
876
+ }, [onClose]);
877
+
878
+ const doUpload = async (f) => {
879
+ if (!f) return;
880
+ setBusy(true);
881
+ setErr(null);
882
+ try {
883
+ const res = await fetch('/_api/asset', {
884
+ method: 'POST',
885
+ headers: { 'content-type': f.type || 'application/octet-stream' },
886
+ body: f,
887
+ });
888
+ const j = await res.json().catch(() => ({}));
889
+ // /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
890
+ // `j.ok` check always failed → "upload failed" even on a good upload).
891
+ if (res.ok && j.path) {
892
+ onPick(j.path);
893
+ return;
894
+ }
895
+ setErr(j.error || `upload failed (HTTP ${res.status})`);
896
+ } catch {
897
+ setErr('upload failed');
898
+ } finally {
899
+ setBusy(false);
900
+ }
901
+ };
902
+
903
+ // Native desktop (Tauri WKWebView) — an HTML <input type=file> won't present
904
+ // the file panel AT ALL here, so route through the same native-dialog spine
905
+ // export uses (dogfood: "pri exportu to uz umime"). The Rust pick_media_file
906
+ // command opens an OS open-dialog + reads the bytes; we POST them to
907
+ // /_api/asset (magic-byte sniffed, so name/ext aren't trusted).
908
+ const openFilePickerNative = async () => {
909
+ setBusy(true);
910
+ setErr(null);
911
+ try {
912
+ const picked = await pickMediaFile();
913
+ if (picked?.bytes) {
914
+ // Blob from the byte array; the server sniffs the type, so no content-type
915
+ // needed. (doUpload sets its own busy=false in finally.)
916
+ await doUpload(new Blob([new Uint8Array(picked.bytes)]));
917
+ return;
918
+ }
919
+ } catch (e) {
920
+ setErr(e?.message || 'open failed');
921
+ }
922
+ setBusy(false); // only reached on cancel / error (doUpload owns the success path)
923
+ };
924
+
925
+ // Browser — imperative <input>, mirrors the proven replaceMediaViaPicker
926
+ // pattern (freshly created, appended to document.body, clicked synchronously in
927
+ // the gesture). Off-screen (not display:none) so it lays out.
928
+ const openFilePicker = () => {
929
+ if (isNativeApp()) {
930
+ openFilePickerNative();
931
+ return;
932
+ }
933
+ const input = document.createElement('input');
934
+ input.type = 'file';
935
+ input.accept = 'image/*,video/*';
936
+ input.style.cssText =
937
+ 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
938
+ document.body.appendChild(input);
939
+ const cleanup = () => {
940
+ if (input.isConnected) input.remove();
941
+ };
942
+ window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
943
+ input.addEventListener('change', () => {
944
+ const file = input.files?.[0];
945
+ cleanup();
946
+ if (file) doUpload(file);
947
+ });
948
+ input.click();
949
+ };
950
+
951
+ const assetUrl = (p) => `/${designRel}/${p}`;
952
+
953
+ return (
954
+ <div
955
+ className="st-scrim"
956
+ role="presentation"
957
+ onMouseDown={(e) => {
958
+ if (e.target === e.currentTarget) onClose();
959
+ }}
960
+ >
961
+ <div className="st-dialog st-asset-picker" role="dialog" aria-modal="true" aria-label="Choose media">
962
+ <div className="st-dialog-hd">
963
+ <span className="st-dialog-title">Choose media</span>
964
+ <button type="button" className="st-iconbtn" aria-label="Close" onClick={onClose}>
965
+ <StIcon name="x" size={15} />
966
+ </button>
967
+ </div>
968
+ <div className="st-dialog-bd">
969
+ <div className="st-ap-toolbar">
970
+ <button type="button" className="st-btn" onClick={openFilePicker} disabled={busy}>
971
+ {busy ? 'Uploading…' : 'Upload…'}
972
+ </button>
973
+ {err && <span className="st-ap-err">{err}</span>}
974
+ </div>
975
+ <div className="st-ap-grid">
976
+ {assets == null ? (
977
+ <div className="st-ap-empty">Loading…</div>
978
+ ) : assets.length === 0 ? (
979
+ <div className="st-ap-empty">No assets yet — upload one.</div>
980
+ ) : (
981
+ assets.map((a) => (
982
+ <button
983
+ type="button"
984
+ key={a.path}
985
+ className="st-ap-cell"
986
+ title={`${a.name} · ${Math.max(1, Math.round(a.size / 1024))} KB`}
987
+ onClick={() => onPick(a.path)}
988
+ >
989
+ {a.kind === 'video' ? (
990
+ <video className="st-ap-thumb" src={assetUrl(a.path)} muted playsInline />
991
+ ) : (
992
+ <img className="st-ap-thumb" src={assetUrl(a.path)} alt={a.name} loading="lazy" />
993
+ )}
994
+ <span className="st-ap-name">{a.name}</span>
995
+ </button>
996
+ ))
997
+ )}
998
+ </div>
999
+ </div>
1000
+ </div>
1001
+ </div>
1002
+ );
1003
+ }
1004
+
842
1005
  function ExportDialog({
843
1006
  mode,
844
1007
  initialScope,
@@ -2456,7 +2619,7 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
2456
2619
  );
2457
2620
  }
2458
2621
 
2459
- function EditDropdown({ onAction, onClose }) {
2622
+ function EditDropdown({ onAction, onClose, hasCanvas }) {
2460
2623
  return (
2461
2624
  <DropdownMenu
2462
2625
  label="Edit"
@@ -2469,6 +2632,17 @@ function EditDropdown({ onAction, onClose }) {
2469
2632
  { sep: true },
2470
2633
  { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
2471
2634
  { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
2635
+ // Stage I4 — insert an empty artboard from a device-size preset into the
2636
+ // active canvas. Only meaningful with a canvas open.
2637
+ ...(hasCanvas
2638
+ ? [
2639
+ { sep: true },
2640
+ { id: 'new-artboard:desktop', label: 'New artboard: Desktop' },
2641
+ { id: 'new-artboard:laptop', label: 'New artboard: Laptop' },
2642
+ { id: 'new-artboard:tablet', label: 'New artboard: Tablet' },
2643
+ { id: 'new-artboard:mobile', label: 'New artboard: Mobile' },
2644
+ ]
2645
+ : []),
2472
2646
  ]}
2473
2647
  />
2474
2648
  );
@@ -2511,6 +2685,8 @@ function Menubar({
2511
2685
  inspectorOpen,
2512
2686
  inspectorTab,
2513
2687
  onToggleInspector,
2688
+ autoOpenInspector,
2689
+ onToggleAutoOpenInspector,
2514
2690
  onOpenLayers,
2515
2691
  timelineOpen,
2516
2692
  onToggleTimeline,
@@ -2524,6 +2700,7 @@ function Menubar({
2524
2700
  onOpenExport,
2525
2701
  onReload,
2526
2702
  onCloseCanvas,
2703
+ onInsertArtboard,
2527
2704
  }) {
2528
2705
  const isSystem = activePath === SYSTEM_TAB;
2529
2706
  const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
@@ -2574,6 +2751,13 @@ function Menubar({
2574
2751
  checked: inspectorOpen,
2575
2752
  disabled: false,
2576
2753
  },
2754
+ {
2755
+ id: 'autoopen',
2756
+ label: 'Auto-open Inspector on select',
2757
+ shortcut: '',
2758
+ checked: !!autoOpenInspector,
2759
+ disabled: false,
2760
+ },
2577
2761
  // DDR-148 — Timeline (video-comp scrub). Phase-tag hints when the active
2578
2762
  // canvas actually has a comp; the panel itself shows an empty state otherwise.
2579
2763
  {
@@ -2728,12 +2912,15 @@ function Menubar({
2728
2912
  )}
2729
2913
  {openMenu === 'edit' && (
2730
2914
  <EditDropdown
2915
+ hasCanvas={!!activePath && !isSystem}
2731
2916
  onAction={(id) => {
2732
2917
  if (id === 'undo') postToActiveCanvas({ dgn: 'undo' });
2733
2918
  else if (id === 'redo') postToActiveCanvas({ dgn: 'redo' });
2734
2919
  else if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
2735
2920
  else if (id === 'select-all-annotations')
2736
2921
  postToActiveCanvas({ dgn: 'annotation-select-all' });
2922
+ else if (id.startsWith('new-artboard:'))
2923
+ onInsertArtboard?.(id.slice('new-artboard:'.length));
2737
2924
  }}
2738
2925
  onClose={() => setOpenMenu(null)}
2739
2926
  />
@@ -2748,6 +2935,7 @@ function Menubar({
2748
2935
  else if (id === 'hidden') onToggleShowHidden();
2749
2936
  else if (id === 'annotate') onToggleAnnotations();
2750
2937
  else if (id === 'inspector') onToggleInspector();
2938
+ else if (id === 'autoopen') onToggleAutoOpenInspector?.();
2751
2939
  else if (id === 'timeline') onToggleTimeline?.();
2752
2940
  else if (id === 'assistant') onToggleAssistant?.();
2753
2941
  else if (id === 'layers') onOpenLayers?.();
@@ -3636,7 +3824,10 @@ function SyncBanner({ status }) {
3636
3824
 
3637
3825
  const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
3638
3826
  const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
3827
+ const CSS_FLEX_WRAP = ['nowrap', 'wrap', 'wrap-reverse'];
3639
3828
  const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
3829
+ // Stage M — flex-CHILD align-self (adds `auto` to the container align-items set).
3830
+ const CSS_ALIGN_SELF = ['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
3640
3831
  const CSS_JUSTIFY = [
3641
3832
  'flex-start',
3642
3833
  'center',
@@ -3675,6 +3866,27 @@ const PROP_LEAD = {
3675
3866
  opacity: { icon: 'p-opacity' },
3676
3867
  };
3677
3868
  const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
3869
+ // feature-element-editing-robustness Stage B — enum option lists for the promoted
3870
+ // DDR-104 OUT-list knobs (Position / Typography extras / Media framing).
3871
+ const CSS_POSITION = ['static', 'relative', 'absolute', 'fixed', 'sticky'];
3872
+ const CSS_FONT_STYLE = ['normal', 'italic', 'oblique'];
3873
+ const CSS_TEXT_TRANSFORM = ['none', 'uppercase', 'lowercase', 'capitalize'];
3874
+ const CSS_TEXT_DECORATION = ['none', 'underline', 'line-through', 'overline'];
3875
+ const CSS_WHITE_SPACE = ['normal', 'nowrap', 'pre', 'pre-wrap', 'pre-line', 'break-spaces'];
3876
+ const CSS_OBJECT_FIT = ['fill', 'contain', 'cover', 'none', 'scale-down'];
3877
+ const CSS_OVERFLOW = ['visible', 'hidden', 'auto', 'scroll'];
3878
+ // Common aspect ratios for the Media dropdown (dogfood request — a select, not a
3879
+ // free-text field). Canonical spaced form so a dropdown-set value round-trips.
3880
+ const CSS_ASPECT_RATIO = ['auto', '1 / 1', '4 / 3', '3 / 2', '16 / 9', '21 / 9', '3 / 4', '2 / 3', '9 / 16'];
3881
+
3882
+ // feature-element-editing-robustness Stage I4 — device presets for the "New
3883
+ // artboard" menu (inserts an empty <DCArtboard> of these dims into the canvas).
3884
+ const SCREEN_PRESETS = {
3885
+ desktop: { label: 'Desktop', width: 1440, height: 1024 },
3886
+ laptop: { label: 'Laptop', width: 1280, height: 800 },
3887
+ tablet: { label: 'Tablet', width: 834, height: 1194 },
3888
+ mobile: { label: 'Mobile', width: 390, height: 844 },
3889
+ };
3678
3890
 
3679
3891
  let _cssColorCtx = null;
3680
3892
  // Normalize any CSS color string to #rrggbb for the native color input via a
@@ -4277,7 +4489,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
4277
4489
  );
4278
4490
  }
4279
4491
 
4280
- function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4492
+ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo }) {
4281
4493
  const editable = !!el.id;
4282
4494
  const computed = el.computed || {};
4283
4495
  // Phase 12.3 — optimistic local overlay over the selection's authored / custom
@@ -4321,9 +4533,11 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4321
4533
  const [status, setStatus] = useState({});
4322
4534
  const [open, setOpen] = useState({
4323
4535
  Layout: true,
4536
+ Position: true,
4324
4537
  Typography: true,
4325
4538
  Spacing: true,
4326
4539
  Size: true,
4540
+ Media: true,
4327
4541
  Appearance: true,
4328
4542
  Advanced: false,
4329
4543
  });
@@ -4437,6 +4651,48 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4437
4651
  post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
4438
4652
  record('attr', attr, before, null);
4439
4653
  };
4654
+ // Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
4655
+ // `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
4656
+ // → flex-grow, cross axis → align-self, block/grid → 100%) + the fill-props to
4657
+ // clear. Each ride the same commit/reset lanes (per-prop edit-css + undo record);
4658
+ // the server's per-file lock serializes them, so the resulting box is coherent.
4659
+ const parentLayout = { display: el.parentDisplay, flexDirection: el.parentFlexDirection };
4660
+ const applySizing = (axis, mode) => {
4661
+ if (!editable) return;
4662
+ const px = Math.round((axis === 'width' ? el.bounds?.w : el.bounds?.h) || 0);
4663
+ const patch = sizingModePatch(axis, mode, parentLayout, px);
4664
+ for (const p of patch.reset) if (authored[p]) reset(p);
4665
+ for (const [prop, value] of patch.set) commit(prop, value);
4666
+ };
4667
+ const parentIsFlexChild =
4668
+ el.parentDisplay === 'flex' || el.parentDisplay === 'inline-flex';
4669
+ const sizeModeSeg = (axis) => {
4670
+ const cur = sizingModeOf(axis, authored, computed, parentLayout);
4671
+ return (
4672
+ <div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
4673
+ <span className="st-cp-modeax" aria-hidden="true">
4674
+ {axis === 'width' ? 'W' : 'H'}
4675
+ </span>
4676
+ {[
4677
+ ['fixed', 'Fixed'],
4678
+ ['hug', 'Hug'],
4679
+ ['fill', 'Fill'],
4680
+ ].map(([m, label]) => (
4681
+ <button
4682
+ key={m}
4683
+ type="button"
4684
+ className={`st-cp-modebtn${cur === m ? ' is-active' : ''}`}
4685
+ aria-pressed={cur === m}
4686
+ disabled={!editable}
4687
+ onClick={() => applySizing(axis, m)}
4688
+ title={`${label} ${axis}`}
4689
+ >
4690
+ {label}
4691
+ </button>
4692
+ ))}
4693
+ </div>
4694
+ );
4695
+ };
4440
4696
  // Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
4441
4697
  // undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
4442
4698
  // not field text. Without this, an edit committed with focus still in the
@@ -4595,7 +4851,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4595
4851
 
4596
4852
  // Props each section owns — drives the per-section "reset section" affordance.
4597
4853
  const SECTION_PROPS = {
4598
- Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'gap'],
4854
+ Layout: ['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap'],
4855
+ // feature-element-editing-robustness Stage B — promoted DDR-104 OUT-list.
4856
+ Position: ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
4599
4857
  Typography: [
4600
4858
  'font-family',
4601
4859
  'color',
@@ -4604,6 +4862,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4604
4862
  'line-height',
4605
4863
  'letter-spacing',
4606
4864
  'text-align',
4865
+ 'font-style',
4866
+ 'text-transform',
4867
+ 'text-decoration',
4868
+ 'white-space',
4607
4869
  ],
4608
4870
  Spacing: [
4609
4871
  'margin-top',
@@ -4615,7 +4877,22 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4615
4877
  'padding-bottom',
4616
4878
  'padding-left',
4617
4879
  ],
4618
- Size: ['width', 'height', 'max-width'],
4880
+ Size: [
4881
+ 'width',
4882
+ 'height',
4883
+ 'min-width',
4884
+ 'min-height',
4885
+ 'max-width',
4886
+ 'max-height',
4887
+ 'overflow',
4888
+ // Stage M — flex-child sizing props (written by the Fill mode + shown as rows
4889
+ // when the parent is flex). Included so a section-reset clears them too.
4890
+ 'flex-grow',
4891
+ 'flex-shrink',
4892
+ 'flex-basis',
4893
+ 'align-self',
4894
+ ],
4895
+ Media: ['object-fit', 'aspect-ratio', 'object-position'],
4619
4896
  Appearance: [
4620
4897
  'background-color',
4621
4898
  'border-radius',
@@ -4628,6 +4905,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4628
4905
  'border-color',
4629
4906
  'box-shadow',
4630
4907
  'opacity',
4908
+ 'transform',
4909
+ 'transform-origin',
4631
4910
  ],
4632
4911
  };
4633
4912
  const resetSection = (name) => {
@@ -4863,6 +5142,41 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4863
5142
  );
4864
5143
  };
4865
5144
 
5145
+ // feature-element-editing-robustness Stage B (Task B3) — a position INSET side
5146
+ // (top/right/bottom/left). Mirrors `side()` but for the bare inset longhands
5147
+ // (no group prefix), allowing NEGATIVE values and an `auto` default, and reuses
5148
+ // the same box-model scrub grammar: alt = the axis pair, alt+shift = all four.
5149
+ const inset = (prop) => {
5150
+ const a = authored[prop];
5151
+ const shown =
5152
+ a != null && a !== '' && a !== 'auto'
5153
+ ? cssSplitUnit(a).n || a
5154
+ : cssSplitUnit(cssHint(computed[prop]) ?? '').n || '';
5155
+ const isZero = !a || a === 'auto' || a === '0' || a === '0px';
5156
+ const pair = prop === 'top' || prop === 'bottom' ? ['top', 'bottom'] : ['left', 'right'];
5157
+ const all = ['top', 'right', 'bottom', 'left'];
5158
+ return (
5159
+ <input
5160
+ className={`st-cp-boxv st-cp-scrub st-cp-boxv--i${prop[0]}${isZero ? ' is-zero' : ''}`}
5161
+ key={`${prop}:${a ?? ''}`}
5162
+ aria-label={prop}
5163
+ defaultValue={shown}
5164
+ placeholder="auto"
5165
+ title="drag to scrub · alt = axis pair · alt+shift = all sides · type auto"
5166
+ onPointerDown={makeScrub(prop, { sides: { pair, all }, min: -Infinity })}
5167
+ onKeyDown={(e) => {
5168
+ if (e.key === 'Enter') e.currentTarget.blur();
5169
+ }}
5170
+ onBlur={(e) => {
5171
+ const raw = e.currentTarget.value.trim();
5172
+ if (!raw) return;
5173
+ const val = /[a-z%]/i.test(raw) ? raw : `${raw}px`;
5174
+ commit(prop, val);
5175
+ }}
5176
+ />
5177
+ );
5178
+ };
5179
+
4866
5180
  const corner = (label, prop) => (
4867
5181
  <label className="st-cp-cornerf">
4868
5182
  <span>{label}</span>
@@ -4899,12 +5213,66 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4899
5213
 
4900
5214
  {sec(
4901
5215
  'Layout',
5216
+ (() => {
5217
+ // Stage M2 — auto-layout editor. Present the flex vocabulary (Direction ·
5218
+ // Wrap · Distribution · Align · Gap) only when the element IS a flex/grid
5219
+ // container, so a plain block doesn't carry knobs that do nothing; a
5220
+ // non-container gets a one-click "make it a flex layout" instead (the
5221
+ // DDR-104 gap-degrades-gracefully precedent). align-items / justify-content
5222
+ // / gap apply to grid too; flex-direction / flex-wrap are flex-only.
5223
+ const disp = (authored.display || cssHint(computed.display) || '').trim();
5224
+ const isFlex = disp === 'flex' || disp === 'inline-flex';
5225
+ const isGrid = disp === 'grid' || disp === 'inline-grid';
5226
+ return (
5227
+ <>
5228
+ {row('display', csel('display', CSS_DISPLAYS))}
5229
+ {isFlex ? (
5230
+ <>
5231
+ {row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
5232
+ {row('flex-wrap', csel('flex-wrap', CSS_FLEX_WRAP))}
5233
+ </>
5234
+ ) : null}
5235
+ {isFlex || isGrid ? (
5236
+ <>
5237
+ {row('align-items', csel('align-items', CSS_ALIGN))}
5238
+ {row('justify-content', csel('justify-content', CSS_JUSTIFY))}
5239
+ {row('gap', num('gap', 'space'))}
5240
+ </>
5241
+ ) : (
5242
+ <button
5243
+ type="button"
5244
+ className="st-cp-makeflex"
5245
+ disabled={!editable}
5246
+ onClick={() => commit('display', 'flex')}
5247
+ >
5248
+ + Auto layout (flex)
5249
+ </button>
5250
+ )}
5251
+ </>
5252
+ );
5253
+ })()
5254
+ )}
5255
+
5256
+ {sec(
5257
+ 'Position',
4902
5258
  <>
4903
- {row('display', csel('display', CSS_DISPLAYS))}
4904
- {row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
4905
- {row('align-items', csel('align-items', CSS_ALIGN))}
4906
- {row('justify-content', csel('justify-content', CSS_JUSTIFY))}
4907
- {row('gap', num('gap', 'space'))}
5259
+ {row('position', csel('position', CSS_POSITION))}
5260
+ <div className="st-cp-box st-cp-box--inset" aria-label="position inset (top / right / bottom / left)">
5261
+ <span className="st-cp-boxtag st-cp-boxtag--i">{prov(provOf('top'))}inset</span>
5262
+ {inset('top')}
5263
+ {inset('right')}
5264
+ {inset('bottom')}
5265
+ {inset('left')}
5266
+ <div className="st-cp-boxcore st-cp-boxcore--pos">
5267
+ {authored.position || cssHint(computed.position) || 'static'}
5268
+ </div>
5269
+ </div>
5270
+ {(authored.position || cssHint(computed.position) || 'static') === 'static' ? (
5271
+ <div className="st-cp-note">
5272
+ top / right / bottom / left apply once position is relative, absolute, fixed, or sticky
5273
+ </div>
5274
+ ) : null}
5275
+ {row('z-index', num('z-index'))}
4908
5276
  </>
4909
5277
  )}
4910
5278
 
@@ -4938,6 +5306,11 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4938
5306
  ))}
4939
5307
  </div>
4940
5308
  )}
5309
+ {/* Stage B (Task B4) — promoted typography knobs (was DDR-104 OUT-list). */}
5310
+ {row('font-style', csel('font-style', CSS_FONT_STYLE))}
5311
+ {row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM))}
5312
+ {row('text-decoration', csel('text-decoration', CSS_TEXT_DECORATION))}
5313
+ {row('white-space', csel('white-space', CSS_WHITE_SPACE))}
4941
5314
  </>
4942
5315
  )}
4943
5316
 
@@ -4971,12 +5344,103 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
4971
5344
  {sec(
4972
5345
  'Size',
4973
5346
  <>
5347
+ {/* Stage M1 — per-axis Fixed / Hug / Fill sizing mode (Figma parity). The
5348
+ Fixed case leaves the numeric width/height knobs below in control. */}
5349
+ <div className="st-cp-modes">
5350
+ {sizeModeSeg('width')}
5351
+ {sizeModeSeg('height')}
5352
+ </div>
4974
5353
  {row('width', num('width'))}
4975
5354
  {row('height', num('height'))}
5355
+ {row('min-width', num('min-width'))}
4976
5356
  {row('max-width', num('max-width'))}
5357
+ {row('min-height', num('min-height'))}
5358
+ {row('max-height', num('max-height'))}
5359
+ {row('overflow', csel('overflow', CSS_OVERFLOW))}
5360
+ {/* Stage M1 — flex-CHILD controls, only meaningful when the parent is a
5361
+ flex container. align-self is the cross-axis override; flex-grow/shrink/
5362
+ basis are the main-axis behavior the Fill mode writes for you. */}
5363
+ {parentIsFlexChild ? (
5364
+ <>
5365
+ <div className="st-cp-subhd">In flex parent</div>
5366
+ {row('align-self', csel('align-self', CSS_ALIGN_SELF))}
5367
+ {row('flex-grow', num('flex-grow', null, { unitless: true }))}
5368
+ {row('flex-shrink', num('flex-shrink', null, { unitless: true }))}
5369
+ {row('flex-basis', num('flex-basis'))}
5370
+ </>
5371
+ ) : null}
4977
5372
  </>
4978
5373
  )}
4979
5374
 
5375
+ {/* Stage B (Task B5) — Media framing. Rendered only for a media element
5376
+ (img / video / picture / svg / canvas) or a selection that already
5377
+ carries a framing prop, so a plain <div> doesn't grow object-fit rows.
5378
+ Media = box/framing/source (this plan); the photo-editor plan's "Photo"
5379
+ tab owns pixels/look — they are separate DOM slots by design. */}
5380
+ {(() => {
5381
+ const t = (el.tag || '').toLowerCase();
5382
+ const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
5383
+ const showMedia =
5384
+ isMediaEl ||
5385
+ !!authored['object-fit'] ||
5386
+ !!authored['object-position'] ||
5387
+ !!authored['aspect-ratio'];
5388
+ // Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
5389
+ // <img>/<video> only; a template-expression src can't be string-swapped,
5390
+ // so gate on a real src attr being present).
5391
+ const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
5392
+ return showMedia
5393
+ ? sec(
5394
+ 'Media',
5395
+ <>
5396
+ {canReplace && (
5397
+ <div className="st-cp-mediabtn">
5398
+ <button
5399
+ type="button"
5400
+ className="st-btn st-cp-replace"
5401
+ onClick={() => onReplaceMedia(el)}
5402
+ >
5403
+ Replace…
5404
+ </button>
5405
+ </div>
5406
+ )}
5407
+ {row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
5408
+ {row('object-position', text('object-position'))}
5409
+ {row(
5410
+ 'aspect-ratio',
5411
+ <select
5412
+ className="st-cp-nsel"
5413
+ aria-label="aspect-ratio"
5414
+ value={
5415
+ CSS_ASPECT_RATIO.includes(authored['aspect-ratio'])
5416
+ ? authored['aspect-ratio']
5417
+ : ''
5418
+ }
5419
+ onChange={(e) => {
5420
+ const v = e.target.value;
5421
+ commit('aspect-ratio', v);
5422
+ // A fixed height overrides aspect-ratio (CSS: explicit
5423
+ // width+height win). When applying a real ratio, release the
5424
+ // height so the ratio actually reshapes the box (dogfood:
5425
+ // "nastavil jsem 16/9 a nic se nestalo").
5426
+ if (v && v !== 'auto' && authored.height) reset('height');
5427
+ }}
5428
+ >
5429
+ <option value="" disabled>
5430
+ {cssHint(computed['aspect-ratio']) || '—'}
5431
+ </option>
5432
+ {CSS_ASPECT_RATIO.map((v) => (
5433
+ <option key={v} value={v}>
5434
+ {v}
5435
+ </option>
5436
+ ))}
5437
+ </select>
5438
+ )}
5439
+ </>
5440
+ )
5441
+ : null;
5442
+ })()}
5443
+
4980
5444
  {sec(
4981
5445
  'Appearance',
4982
5446
  <>
@@ -5060,6 +5524,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
5060
5524
  />
5061
5525
  </div>
5062
5526
  )}
5527
+ {/* Stage B (Task B4) — transform as a free-value row (mirrors box-shadow). */}
5528
+ {row('transform', text('transform'))}
5529
+ {row('transform-origin', text('transform-origin'))}
5063
5530
  </>
5064
5531
  )}
5065
5532
 
@@ -5325,7 +5792,7 @@ function LayerRow({
5325
5792
  selectedId,
5326
5793
  selectedIndex,
5327
5794
  collapsed,
5328
- hidden,
5795
+ hiddenOverride,
5329
5796
  onToggle,
5330
5797
  onSelect,
5331
5798
  onHover,
@@ -5342,7 +5809,7 @@ function LayerRow({
5342
5809
  // clones at once. Fall back to id-only when the selection carries no index.
5343
5810
  const isSel =
5344
5811
  node.id === selectedId && (selectedIndex == null || node.index === selectedIndex);
5345
- const isHidden = hidden?.has(key);
5812
+ const isHidden = hiddenOverride?.has(key) ? hiddenOverride.get(key) : !!node.hidden;
5346
5813
  // A shared data-cd-id (reused component instance) IS reorderable now — the
5347
5814
  // server maps the occurrence index to the parent <Component> usage — so these
5348
5815
  // are no longer greyed/blocked. A `.map()`ed single-usage element still can't
@@ -5435,7 +5902,7 @@ function LayerRow({
5435
5902
  selectedId={selectedId}
5436
5903
  selectedIndex={selectedIndex}
5437
5904
  collapsed={collapsed}
5438
- hidden={hidden}
5905
+ hiddenOverride={hiddenOverride}
5439
5906
  onToggle={onToggle}
5440
5907
  onSelect={onSelect}
5441
5908
  onHover={onHover}
@@ -5515,10 +5982,123 @@ function InspectComputed({ el }) {
5515
5982
  );
5516
5983
  }
5517
5984
 
5985
+ // Dogfood 2026-07-07 — resolve the artboard id for a whole-artboard selection
5986
+ // defensively: prefer `el.artboardId` (set by `hoverTargetToSelection` for a
5987
+ // live chrome click), but fall back to parsing it out of `el.selector` — some
5988
+ // selection-construction paths (Layers-tree artboard row, a restored
5989
+ // `_active.json` selection) may not carry `artboardId` even though the
5990
+ // selector is always the `[data-dc-screen="…"]` chrome form for one.
5991
+ function resolveArtboardIdFromSelection(el) {
5992
+ if (el.artboardId) return el.artboardId;
5993
+ const m = /^\[data-dc-screen="([^"]+)"\]$/.exec(el.selector || '');
5994
+ return m ? m[1] : null;
5995
+ }
5996
+
5997
+ // Dogfood 2026-07-07 — the CSS tab showed everything disabled for a
5998
+ // whole-ARTBOARD selection (`CssKnobs`'s `editable = !!el.id`, and an artboard
5999
+ // chrome click has NO data-cd-id — DCArtboard doesn't forward it to the DOM).
6000
+ // A dedicated, MUCH smaller panel: exact width/height fields (writes via
6001
+ // /_api/resize-artboard, NOT edit-css — DDR-027 numeric JSX props) + the SAME
6002
+ // SCREEN_PRESETS the "+ Artboard" menu uses, so picking "Tablet" resizes the
6003
+ // CURRENT artboard to 834×1194 in one click instead of typing both fields.
6004
+ function ArtboardKnobs({ el, onResizeArtboard }) {
6005
+ const artboardId = resolveArtboardIdFromSelection(el);
6006
+ // Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
6007
+ // undefined for a selection that reached here via a code path predating
6008
+ // that field (a canvas iframe that hasn't remounted since); fall back to
6009
+ // `bounds` (the SCREEN rect — always populated, but wrong at any zoom other
6010
+ // than 100%) so the fields show SOMETHING rather than sit empty. Self-heals
6011
+ // to the exact value the moment `worldW`/`worldH` are present.
6012
+ const w = Number.isFinite(el.worldW)
6013
+ ? el.worldW
6014
+ : Number.isFinite(el.bounds?.w)
6015
+ ? el.bounds.w
6016
+ : null;
6017
+ const h = Number.isFinite(el.worldH)
6018
+ ? el.worldH
6019
+ : Number.isFinite(el.bounds?.h)
6020
+ ? el.bounds.h
6021
+ : null;
6022
+ const commitSize = (width, height) => {
6023
+ if (!artboardId) return;
6024
+ const nw = Number.isFinite(width) && width > 0 ? Math.round(width) : undefined;
6025
+ const nh = Number.isFinite(height) && height > 0 ? Math.round(height) : undefined;
6026
+ if (nw == null && nh == null) return;
6027
+ onResizeArtboard?.(artboardId, nw, nh);
6028
+ };
6029
+ const activePreset = Object.entries(SCREEN_PRESETS).find(
6030
+ ([, p]) => p.width === w && p.height === h
6031
+ )?.[0];
6032
+ return (
6033
+ <section className="st-cp-sec">
6034
+ <div className="st-cp-sechd-row">
6035
+ <span className="st-cp-sechd">Artboard</span>
6036
+ </div>
6037
+ <div style={{ display: 'flex', gap: 8, padding: '4px 12px' }}>
6038
+ <div className="st-cp-num">
6039
+ <span className="st-cp-numlead" aria-hidden="true">
6040
+ W
6041
+ </span>
6042
+ <input
6043
+ className="st-cp-numin"
6044
+ type="number"
6045
+ min="1"
6046
+ aria-label="artboard width"
6047
+ key={`w:${w ?? ''}`}
6048
+ defaultValue={w ?? ''}
6049
+ onKeyDown={(e) => {
6050
+ if (e.key === 'Enter') e.currentTarget.blur();
6051
+ }}
6052
+ onBlur={(e) => commitSize(Number.parseFloat(e.currentTarget.value), null)}
6053
+ />
6054
+ </div>
6055
+ <div className="st-cp-num">
6056
+ <span className="st-cp-numlead" aria-hidden="true">
6057
+ H
6058
+ </span>
6059
+ <input
6060
+ className="st-cp-numin"
6061
+ type="number"
6062
+ min="1"
6063
+ aria-label="artboard height"
6064
+ key={`h:${h ?? ''}`}
6065
+ defaultValue={h ?? ''}
6066
+ onKeyDown={(e) => {
6067
+ if (e.key === 'Enter') e.currentTarget.blur();
6068
+ }}
6069
+ onBlur={(e) => commitSize(null, Number.parseFloat(e.currentTarget.value))}
6070
+ />
6071
+ </div>
6072
+ </div>
6073
+ <div style={{ padding: '0 12px 8px' }}>
6074
+ <select
6075
+ className="st-cp-nsel"
6076
+ aria-label="artboard size preset"
6077
+ value={activePreset ?? ''}
6078
+ onChange={(e) => {
6079
+ const p = SCREEN_PRESETS[e.currentTarget.value];
6080
+ if (p) commitSize(p.width, p.height);
6081
+ }}
6082
+ >
6083
+ <option value="" disabled>
6084
+ {activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
6085
+ </option>
6086
+ {Object.entries(SCREEN_PRESETS).map(([key, p]) => (
6087
+ <option key={key} value={key}>
6088
+ {p.label} — {p.width}×{p.height}
6089
+ </option>
6090
+ ))}
6091
+ </select>
6092
+ </div>
6093
+ </section>
6094
+ );
6095
+ }
6096
+
5518
6097
  function InspectorPanel({
5519
6098
  selected,
5520
6099
  onClose,
5521
6100
  layersTree,
6101
+ canvasFile,
5522
6102
  onSelectLayer,
5523
6103
  onHoverLayer,
5524
6104
  onReorderLayer,
@@ -5526,7 +6106,10 @@ function InspectorPanel({
5526
6106
  cfg,
5527
6107
  onOptimistic,
5528
6108
  onRecordEdit,
6109
+ onReplaceMedia,
6110
+ onResizeArtboard,
5529
6111
  onUndoRedo,
6112
+ editScope,
5530
6113
  tab: tabProp,
5531
6114
  onTabChange,
5532
6115
  width,
@@ -5543,9 +6126,18 @@ function InspectorPanel({
5543
6126
  onTabChange?.(t);
5544
6127
  };
5545
6128
  const [collapsed, setCollapsed] = useState(() => new Set());
5546
- // Phase 12.3 (W3.1) — per-layer visibility toggle. Live-only (display:none via
5547
- // the optimistic apply bus); not persisted to source. Keyed by `${id}:${index}`.
5548
- const [hiddenLayers, setHiddenLayers] = useState(() => new Set());
6129
+ // Phase 12.3 (W3.1) — per-layer visibility toggle. Persists via /_api/edit-css
6130
+ // (property 'display', mirroring CssKnobs' commit/reset) and is undoable via
6131
+ // the same edit-source command (see the undo/redo coverage RCA:
6132
+ // .ai/logs/rca/issue-undo-redo-coverage-gaps.md). Keyed by `${id}:${index}`;
6133
+ // holds only OPTIMISTIC overrides (either direction) over the tree-reported
6134
+ // `node.hidden` (the authoritative source value) so a first render of an
6135
+ // already-hidden element shows the correct eye-icon state without a click.
6136
+ const [hiddenOverride, setHiddenOverride] = useState(() => new Map());
6137
+ const isNodeHidden = (node) => {
6138
+ const key = `${node.id}:${node.index}`;
6139
+ return hiddenOverride.has(key) ? hiddenOverride.get(key) : !!node.hidden;
6140
+ };
5549
6141
  // Phase 12.1 (DDR-138) — drag-to-reorder state (lifted so every row sees the
5550
6142
  // same drop target) + an aria-live announcement for keyboard moves.
5551
6143
  const [dragState, setDragState] = useState(null);
@@ -5786,7 +6378,9 @@ function InspectorPanel({
5786
6378
  });
5787
6379
  const toggleVisibility = (node) => {
5788
6380
  const key = `${node.id}:${node.index}`;
5789
- const willHide = !hiddenLayers.has(key);
6381
+ const wasHidden = isNodeHidden(node);
6382
+ const willHide = !wasHidden;
6383
+ setHiddenOverride((prev) => new Map(prev).set(key, willHide));
5790
6384
  onOptimistic?.({
5791
6385
  id: node.id,
5792
6386
  artboardId: layersTree?.artboardId ?? null,
@@ -5794,11 +6388,26 @@ function InspectorPanel({
5794
6388
  prop: 'display',
5795
6389
  value: willHide ? 'none' : null,
5796
6390
  });
5797
- setHiddenLayers((prev) => {
5798
- const next = new Set(prev);
5799
- if (willHide) next.add(key);
5800
- else next.delete(key);
5801
- return next;
6391
+ if (!canvasFile || !node.id) return;
6392
+ fetch('/_api/edit-css', {
6393
+ method: 'POST',
6394
+ headers: { 'content-type': 'application/json' },
6395
+ body: JSON.stringify(
6396
+ willHide
6397
+ ? { canvas: canvasFile, id: node.id, property: 'display', value: 'none' }
6398
+ : { canvas: canvasFile, id: node.id, property: 'display', reset: true }
6399
+ ),
6400
+ }).catch(() => {});
6401
+ // Record onto the canvas undo stack (Cmd+Z), same as any other inline CSS
6402
+ // edit — fire-and-forget alongside the POST above, mirroring CssKnobs'
6403
+ // commit()/reset() (which don't gate the record on the fetch resolving).
6404
+ onRecordEdit?.({
6405
+ op: 'css',
6406
+ canvas: canvasFile,
6407
+ id: node.id,
6408
+ key: 'display',
6409
+ before: wasHidden ? 'none' : null,
6410
+ after: willHide ? 'none' : null,
5802
6411
  });
5803
6412
  };
5804
6413
  // `selected` may be a single element, an array (multi-select), or null.
@@ -5835,6 +6444,30 @@ function InspectorPanel({
5835
6444
  <StIcon name="x" size={14} />
5836
6445
  </button>
5837
6446
  </div>
6447
+ {/* Stage H (INV-3) — edit-scope strip: is an edit local to this element or
6448
+ shared across N rendered places? Visible across every tab so it's never
6449
+ a surprise. Only for a single element selection with a resolved verdict. */}
6450
+ {el?.id && !(Array.isArray(selected) && selected.length > 1) && editScope ? (
6451
+ <div
6452
+ className={`st-scope st-scope--${editScope.scope}`}
6453
+ title={
6454
+ editScope.scope === 'shared'
6455
+ ? `Editing this element's style changes ${editScope.affects} place${
6456
+ editScope.affects === 1 ? '' : 's'
6457
+ }${
6458
+ editScope.componentName ? ` (component ${editScope.componentName})` : ''
6459
+ }. Move/resize a whole instance to keep it local.`
6460
+ : 'This edit affects only this element.'
6461
+ }
6462
+ >
6463
+ <span className="st-scope-dot" aria-hidden="true" />
6464
+ {editScope.scope === 'shared'
6465
+ ? `Shared${editScope.componentName ? ` · ${editScope.componentName}` : ''} · edits ${
6466
+ editScope.affects
6467
+ } place${editScope.affects === 1 ? '' : 's'}`
6468
+ : 'Local · this element only'}
6469
+ </div>
6470
+ ) : null}
5838
6471
  <div className="st-rp-body">
5839
6472
  {!el ? (
5840
6473
  <div className="st-rp-empty">
@@ -5920,7 +6553,7 @@ function InspectorPanel({
5920
6553
  selectedId={el.id}
5921
6554
  selectedIndex={el.index}
5922
6555
  collapsed={collapsed}
5923
- hidden={hiddenLayers}
6556
+ hiddenOverride={hiddenOverride}
5924
6557
  onToggle={toggleCollapse}
5925
6558
  onSelect={(node) => {
5926
6559
  onSelectLayer?.(node);
@@ -5966,12 +6599,15 @@ function InspectorPanel({
5966
6599
  </div>
5967
6600
  )}
5968
6601
  </>
6602
+ ) : !el.id && resolveArtboardIdFromSelection(el) ? (
6603
+ <ArtboardKnobs el={el} onResizeArtboard={onResizeArtboard} />
5969
6604
  ) : (
5970
6605
  <CssKnobs
5971
6606
  el={el}
5972
6607
  cfg={cfg}
5973
6608
  onOptimistic={onOptimistic}
5974
6609
  onRecordEdit={onRecordEdit}
6610
+ onReplaceMedia={onReplaceMedia}
5975
6611
  onUndoRedo={onUndoRedo}
5976
6612
  />
5977
6613
  )}
@@ -5995,6 +6631,32 @@ function App() {
5995
6631
  useEffect(() => {
5996
6632
  selectedRef.current = selected;
5997
6633
  }, [selected]);
6634
+
6635
+ // Stage H (INV-3) — resolve the edit-scope (local vs shared component instance)
6636
+ // for the current single selection so the Inspector can show whether an edit
6637
+ // stays here or changes N places. Read-only GET, debounced by the selection id;
6638
+ // aborts a stale in-flight fetch when the selection changes. Fetched with
6639
+ // rendered=1 (source-usage-driven; the .map() refinement is a follow-up).
6640
+ const [editScope, setEditScope] = useState(null);
6641
+ useEffect(() => {
6642
+ const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
6643
+ const id = one && typeof one.id === 'string' ? one.id : null;
6644
+ if (!id || !activePath) {
6645
+ setEditScope(null);
6646
+ return;
6647
+ }
6648
+ const ac = new AbortController();
6649
+ const q = new URLSearchParams({ canvas: activePath, id });
6650
+ fetch(`/_api/edit-scope?${q}`, { signal: ac.signal })
6651
+ .then((r) => (r.ok ? r.json() : null))
6652
+ .then((j) => {
6653
+ if (j?.ok) setEditScope(j);
6654
+ })
6655
+ .catch(() => {
6656
+ /* aborted / offline — leave the last verdict, the badge just won't show */
6657
+ });
6658
+ return () => ac.abort();
6659
+ }, [selected, activePath]);
5998
6660
  // feature-acp-context-hardening — halo re-apply retry ladder. A single
5999
6661
  // select-by-id post races the fresh iframe: dgn:'loaded' fires from the
6000
6662
  // inline inspector script at HTML-parse time, BEFORE the React canvas-shell
@@ -6039,6 +6701,14 @@ function App() {
6039
6701
  // (same freshness idiom as selectedRef; avoids a render-time TDZ on the
6040
6702
  // later useCallback).
6041
6703
  const reorderLayerRef = useRef(null);
6704
+ // Same freshness idiom for `repositionElement` (defined far below), read
6705
+ // when the in-canvas drag posts dgn:'reposition-request' — the coordinate-
6706
+ // mode commit for out-of-flow (position:absolute/fixed) elements.
6707
+ const repositionElementRef = useRef(null);
6708
+ // feature-element-editing-robustness Stage D — sibling ref for `resizeElement`
6709
+ // (defined far below), read when the in-canvas resize overlay posts
6710
+ // dgn:'resize-request' on pointer-up. Same origin-split as reposition.
6711
+ const resizeElementRef = useRef(null);
6042
6712
  // Phase 12.1 — true between a layers-panel reorder and the fresh layers-tree
6043
6713
  // landing. A reorder churns positional data-cd-ids, so a rapid 2nd drag would
6044
6714
  // target stale ids; the Layers tree gates new drags on this until the rebuilt
@@ -6277,6 +6947,33 @@ function App() {
6277
6947
  // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
6278
6948
  // tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
6279
6949
  const [inspectorTab, setInspectorTab] = useState('inspect');
6950
+ // feature-element-editing-robustness Stage C — auto-open the Inspector on the
6951
+ // CSS tab when a fresh single selection arrives AND no right panel is already
6952
+ // open. Preference-backed (default ON); disable it in the View menu. Refs keep
6953
+ // the postMessage selection listener stale-closure-safe.
6954
+ const [autoOpenInspector, setAutoOpenInspector] = useState(() => {
6955
+ try {
6956
+ return localStorage.getItem('maude-auto-open-inspector') !== '0';
6957
+ } catch {
6958
+ return true;
6959
+ }
6960
+ });
6961
+ const autoOpenInspectorRef = useRef(autoOpenInspector);
6962
+ useEffect(() => {
6963
+ autoOpenInspectorRef.current = autoOpenInspector;
6964
+ try {
6965
+ localStorage.setItem('maude-auto-open-inspector', autoOpenInspector ? '1' : '0');
6966
+ } catch {
6967
+ /* private mode / storage disabled */
6968
+ }
6969
+ }, [autoOpenInspector]);
6970
+ // Whether ANY right-dock panel is open — the auto-open guard reads this ref so
6971
+ // it never steals focus from an already-open panel (the user's explicit rule).
6972
+ const anyRightPanelOpenRef = useRef(false);
6973
+ useEffect(() => {
6974
+ anyRightPanelOpenRef.current =
6975
+ inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen;
6976
+ }, [inspectorOpen, commentsPanelOpen, changesOpen, assistantOpen]);
6280
6977
  // The right dock holds exactly ONE panel (Changes / Inspector / Comments) at
6281
6978
  // a time — opening any panel REPLACES whatever was there. These two helpers
6282
6979
  // are the single source of that invariant; every open/toggle path routes
@@ -6292,6 +6989,21 @@ function App() {
6292
6989
  // NOTE: the Timeline is a BOTTOM dock (DDR-148), NOT part of the right-rail
6293
6990
  // mutual-exclusion — it stays open when a right panel opens.
6294
6991
  }, []);
6992
+ // feature-element-editing-robustness Stage C (Task C1) — open the Inspector on
6993
+ // the CSS tab for a fresh SINGLE selection, but only when nothing is already
6994
+ // docked on the right (never override an open Layers/Inspect/Comments panel)
6995
+ // and only when the preference is on. Idempotent: once the inspector is open,
6996
+ // the `anyRightPanelOpen` guard stops it re-firing on the next selection.
6997
+ const maybeAutoOpenInspectorOnSelect = useCallback(
6998
+ (sel) => {
6999
+ if (!autoOpenInspectorRef.current) return;
7000
+ if (!sel || !sel.id) return; // need a stable element id to inspect
7001
+ if (anyRightPanelOpenRef.current) return; // don't steal from an open panel
7002
+ openRightPanel('inspector');
7003
+ setInspectorTab('css');
7004
+ },
7005
+ [openRightPanel]
7006
+ );
6295
7007
  // Functional updates so this is stale-closure-safe inside the keydown /
6296
7008
  // postMessage listeners; opening always clears the sibling panels.
6297
7009
  const toggleRightPanel = useCallback((which) => {
@@ -7560,6 +8272,7 @@ function App() {
7560
8272
  if (m.dgn === 'select' && m.selection) {
7561
8273
  wsSend({ type: 'select', selection: m.selection });
7562
8274
  setSelected(m.selection);
8275
+ maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
7563
8276
  } else if (m.dgn === 'select-set') {
7564
8277
  // Canvas multi-select. Payload shape:
7565
8278
  // null → empty selection
@@ -7576,9 +8289,12 @@ function App() {
7576
8289
  const head = payload[0] ?? null;
7577
8290
  if (head) wsSend({ type: 'select', selection: head });
7578
8291
  setSelected(head);
8292
+ // Stage C — auto-open only for a genuine SINGLE selection, never multi.
8293
+ if (payload.length === 1) maybeAutoOpenInspectorOnSelect(head);
7579
8294
  } else {
7580
8295
  wsSend({ type: 'select', selection: payload });
7581
8296
  setSelected(payload);
8297
+ maybeAutoOpenInspectorOnSelect(payload); // Stage C
7582
8298
  }
7583
8299
  } else if (m.dgn === 'clear-select') {
7584
8300
  wsSend({ type: 'clear-select' });
@@ -7739,6 +8455,175 @@ function App() {
7739
8455
  refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
7740
8456
  });
7741
8457
  }
8458
+ } else if (m.dgn === 'reposition-request') {
8459
+ // Free-XY reposition for out-of-flow elements (position:absolute/
8460
+ // fixed) — the in-canvas drag switches from reorder-mode to
8461
+ // coordinate-mode when the dragged element is out of flow, because
8462
+ // reordering an absolute/fixed element's JSX siblings never changes
8463
+ // where it paints (the "moves then snaps back" RCA). Same trust
8464
+ // model + confused-deputy guard as reorder-request: untrusted canvas
8465
+ // REQUESTS, shell WRITES, pinned to the ACTIVE canvas (never
8466
+ // `m.canvas`, which an untrusted iframe could spoof).
8467
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8468
+ const okShape =
8469
+ typeof m.id === 'string' &&
8470
+ Number.isFinite(m.left) &&
8471
+ Number.isFinite(m.top) &&
8472
+ Number.isFinite(m.beforeLeft) &&
8473
+ Number.isFinite(m.beforeTop);
8474
+ if (e.source === activeWin && okShape) {
8475
+ repositionElementRef.current?.(
8476
+ m.id,
8477
+ m.left,
8478
+ m.top,
8479
+ m.beforeLeft,
8480
+ m.beforeTop,
8481
+ Number.isInteger(m.idIndex) ? m.idIndex : undefined
8482
+ );
8483
+ }
8484
+ } else if (m.dgn === 'resize-request') {
8485
+ // feature-element-editing-robustness Stage D — in-canvas drag-resize
8486
+ // commit. Same trust model + confused-deputy guard as reposition-request:
8487
+ // untrusted canvas REQUESTS, shell WRITES, pinned to the ACTIVE canvas
8488
+ // (never `m.canvas`). Payload: { id, patch:{width,height,left?,top?},
8489
+ // before:{width,height,left,top} } — px strings, before values null when
8490
+ // the prop was unset (reset on undo).
8491
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8492
+ const okShape =
8493
+ typeof m.id === 'string' && m.patch && typeof m.patch === 'object';
8494
+ if (e.source === activeWin && okShape) {
8495
+ resizeElementRef.current?.(
8496
+ m.id,
8497
+ m.patch,
8498
+ m.before,
8499
+ Number.isInteger(m.idIndex) ? m.idIndex : undefined
8500
+ );
8501
+ }
8502
+ } else if (m.dgn === 'delete-request') {
8503
+ // feature-element-editing-robustness Stage I — delete an element (Del key
8504
+ // / context menu / toolbar in the canvas). Same confused-deputy guard as
8505
+ // reorder/reposition/resize: untrusted canvas REQUESTS, shell WRITES,
8506
+ // pinned to the ACTIVE canvas (never `m.canvas`).
8507
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8508
+ if (e.source === activeWin && typeof m.id === 'string') {
8509
+ deleteElementShellRef.current?.(m.id, Number.isInteger(m.idIndex) ? m.idIndex : undefined);
8510
+ }
8511
+ } else if (m.dgn === 'duplicate-request') {
8512
+ // Cmd+D (Task L3) — duplicate the selected element. Confused-deputy gated
8513
+ // + pinned to the active canvas, like the other structural verbs.
8514
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8515
+ if (e.source === activeWin && typeof m.id === 'string') {
8516
+ duplicateElementShellRef.current?.(
8517
+ m.id,
8518
+ Number.isInteger(m.idIndex) ? m.idIndex : undefined
8519
+ );
8520
+ }
8521
+ } else if (m.dgn === 'copy-style') {
8522
+ // Task L4 — capture the current selection's authored style (shell-side).
8523
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8524
+ if (e.source === activeWin) copyStyleRef.current?.();
8525
+ } else if (m.dgn === 'paste-style') {
8526
+ // Task L4 — apply the copied style to the target element.
8527
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8528
+ if (e.source === activeWin && typeof m.id === 'string') pasteStyleRef.current?.(m.id);
8529
+ } else if (m.dgn === 'insert-request') {
8530
+ // Stage I3 — insert a synthesized div/text/image relative to `refId`.
8531
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8532
+ const okShape =
8533
+ typeof m.refId === 'string' &&
8534
+ (m.kind === 'div' || m.kind === 'text' || m.kind === 'image') &&
8535
+ (m.position === 'before' ||
8536
+ m.position === 'after' ||
8537
+ m.position === 'inside-start' ||
8538
+ m.position === 'inside-end');
8539
+ if (e.source === activeWin && okShape) {
8540
+ insertElementShellRef.current?.(m.refId, m.position, m.kind, {
8541
+ src: typeof m.src === 'string' ? m.src : undefined,
8542
+ refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
8543
+ });
8544
+ }
8545
+ } else if (m.dgn === 'insert-image-request') {
8546
+ // Stage F/I3 — "Insert ▸ Image" from the canvas context menu. An image
8547
+ // needs a contained asset src, so the shell opens the AssetPicker; the
8548
+ // pick then drives insertElementShell(kind:'image'). Confused-deputy
8549
+ // gated + pinned to the active canvas like the other request verbs.
8550
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8551
+ const okShape =
8552
+ typeof m.refId === 'string' &&
8553
+ (m.position === 'before' ||
8554
+ m.position === 'after' ||
8555
+ m.position === 'inside-start' ||
8556
+ m.position === 'inside-end');
8557
+ if (e.source === activeWin && okShape) {
8558
+ openAssetPickerRef.current?.({
8559
+ purpose: 'insert-image',
8560
+ canvas: activePath,
8561
+ refId: m.refId,
8562
+ position: m.position,
8563
+ refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
8564
+ });
8565
+ }
8566
+ } else if (m.dgn === 'replace-media-request') {
8567
+ // Stage F2 — "Replace image…" from the canvas context menu. Opens the
8568
+ // AssetPicker in replace mode; the pick re-points src via edit-attr. The
8569
+ // context menu supplies the current src as the undo before-value.
8570
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8571
+ if (e.source === activeWin && typeof m.id === 'string') {
8572
+ openAssetPickerRef.current?.({
8573
+ purpose: 'replace-src',
8574
+ canvas: activePath,
8575
+ id: m.id,
8576
+ before: typeof m.before === 'string' ? m.before : null,
8577
+ });
8578
+ }
8579
+ } else if (m.dgn === 'replace-annotation-media-request') {
8580
+ // Stage F3 — "Replace…" on an annotation ImageStroke/MediaRefStroke.
8581
+ // Opens the SAME AssetPicker; unlike F2 the pick is posted BACK DOWN to
8582
+ // the canvas (no data-cd-id to ride edit-attr — the annotation model owns
8583
+ // its own strokes, so the canvas iframe performs the write itself).
8584
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8585
+ if (e.source === activeWin && typeof m.id === 'string') {
8586
+ openAssetPickerRef.current?.({
8587
+ purpose: 'replace-annotation-media',
8588
+ canvas: activePath,
8589
+ id: m.id,
8590
+ before: typeof m.before === 'string' ? m.before : null,
8591
+ });
8592
+ }
8593
+ } else if (m.dgn === 'insert-artboard-request') {
8594
+ // Stage I4 — insert a new empty artboard from a screen-size preset.
8595
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8596
+ const okShape =
8597
+ typeof m.id === 'string' &&
8598
+ Number.isFinite(m.width) &&
8599
+ Number.isFinite(m.height);
8600
+ if (e.source === activeWin && okShape) {
8601
+ insertArtboardShellRef.current?.({
8602
+ id: m.id,
8603
+ label: typeof m.label === 'string' ? m.label : m.id,
8604
+ width: m.width,
8605
+ height: m.height,
8606
+ });
8607
+ }
8608
+ } else if (m.dgn === 'resize-artboard-request') {
8609
+ // Stage D4 — free-hand artboard resize (numeric width/height props).
8610
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8611
+ const okShape =
8612
+ typeof m.artboardId === 'string' &&
8613
+ (Number.isFinite(m.width) || Number.isFinite(m.height));
8614
+ if (e.source === activeWin && okShape) {
8615
+ resizeArtboardShellRef.current?.(
8616
+ m.artboardId,
8617
+ Number.isFinite(m.width) ? m.width : undefined,
8618
+ Number.isFinite(m.height) ? m.height : undefined
8619
+ );
8620
+ }
8621
+ } else if (m.dgn === 'delete-artboard-request') {
8622
+ // Backspace / context-menu delete of a whole artboard (by its id prop).
8623
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8624
+ if (e.source === activeWin && typeof m.artboardId === 'string') {
8625
+ deleteArtboardShellRef.current?.(m.artboardId);
8626
+ }
7742
8627
  } else if (m.dgn === 'open-inspector') {
7743
8628
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
7744
8629
  openRightPanel('inspector');
@@ -8184,6 +9069,536 @@ function App() {
8184
9069
  reorderLayerRef.current = reorderLayer;
8185
9070
  }, [reorderLayer]);
8186
9071
 
9072
+ // Commit an in-canvas coordinate-mode drag (out-of-flow element: absolute/
9073
+ // fixed) as two sequential single-property writes through the SAME
9074
+ // main-origin endpoint the CSS panel already uses (`/_api/edit-css`, Phase
9075
+ // 12 / DDR-103) — no new write surface. `editStyleProp` upserts into the
9076
+ // existing `style={{}}` object, so the second call (top) lands next to the
9077
+ // first (left) rather than clobbering it. Serialized on the shared
9078
+ // apply-edit chain so it can't race an in-flight write to the same file.
9079
+ const repositionElement = useCallback(
9080
+ (id, left, top, beforeLeft, beforeTop, idIndex) => {
9081
+ if (!id || !activePath) return;
9082
+ const canvas = activePath;
9083
+ // Stage H3 — when the dragged target is a whole component INSTANCE the canvas
9084
+ // passes its DOM-occurrence index; the server routes the left/top write to
9085
+ // that instance's own `<Component/>` usage so moving one instance stays local.
9086
+ const occ = Number.isInteger(idIndex) ? idIndex : undefined;
9087
+ // INV-2 (DDR-105) — arm the reload-suppression window BEFORE the edit-css
9088
+ // writes so the HMR reload is skipped and the canvas doesn't remount + drop
9089
+ // the selection (dogfood: "když pohnu elementem myší, ztratím focus"). Same
9090
+ // fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
9091
+ applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
9092
+ applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
9093
+ const writeProp = (property, value) =>
9094
+ fetch('/_api/edit-css', {
9095
+ method: 'POST',
9096
+ headers: { 'content-type': 'application/json' },
9097
+ body: JSON.stringify({ canvas, id, property, value: `${value}px`, idIndex: occ }),
9098
+ }).then((r) => r.json().catch(() => ({})));
9099
+ editApplyChainRef.current = editApplyChainRef.current
9100
+ .catch(() => {})
9101
+ .then(() => writeProp('left', left))
9102
+ .then((j1) => {
9103
+ if (!j1.ok) throw new Error(j1.error || 'left write failed');
9104
+ return writeProp('top', top);
9105
+ })
9106
+ .then((j2) => {
9107
+ if (!j2.ok) throw new Error(j2.error || 'top write failed');
9108
+ // Record BOTH properties onto the canvas undo stack — two Cmd+Z
9109
+ // steps (top, then left), same as committing two CSS knobs by hand.
9110
+ recordSourceEdit({
9111
+ op: 'css',
9112
+ canvas,
9113
+ id,
9114
+ key: 'left',
9115
+ before: `${beforeLeft}px`,
9116
+ after: `${left}px`,
9117
+ });
9118
+ recordSourceEdit({
9119
+ op: 'css',
9120
+ canvas,
9121
+ id,
9122
+ key: 'top',
9123
+ before: `${beforeTop}px`,
9124
+ after: `${top}px`,
9125
+ });
9126
+ })
9127
+ .catch((err) => {
9128
+ console.warn('[reposition]', err?.message || err);
9129
+ // Nothing (or only `left`) persisted — tell the canvas to restore
9130
+ // the pre-drag inline style so a phantom move doesn't linger.
9131
+ postToActiveCanvas({ dgn: 'reposition-failed' });
9132
+ });
9133
+ },
9134
+ [activePath, postToActiveCanvas, recordSourceEdit, applyOptimisticStyle]
9135
+ );
9136
+ useEffect(() => {
9137
+ repositionElementRef.current = repositionElement;
9138
+ }, [repositionElement]);
9139
+
9140
+ // feature-element-editing-robustness Stage D (Task D3) — commit an in-canvas
9141
+ // drag-resize. `patch` = { width, height, left?, top? } (px strings); left/top
9142
+ // are present only for a top/left-edge drag on an out-of-flow element. Writes
9143
+ // each present property through the SAME main-origin `/_api/edit-css` endpoint
9144
+ // the CSS panel + reposition use (no new write surface), serialized on the
9145
+ // shared apply-edit chain, and records one undo entry per property (as
9146
+ // reposition records left+top). Pinned to `activePath` (never `m.canvas`) —
9147
+ // the confused-deputy guard DDR-138/DDR-054 established for reorder/reposition.
9148
+ const resizeElement = useCallback(
9149
+ (id, patch, before, idIndex) => {
9150
+ if (!id || !activePath || !patch || typeof patch !== 'object') return;
9151
+ const canvas = activePath;
9152
+ const b = before && typeof before === 'object' ? before : {};
9153
+ // Stage H3 — a whole-instance resize carries its DOM-occurrence index so the
9154
+ // width/height/left/top write lands on that instance's own `<Component/>`
9155
+ // usage (local), not the shared inner definition. undefined for a plain element.
9156
+ const occ = Number.isInteger(idIndex) ? idIndex : undefined;
9157
+ // `transform` rides the same lane for the rotate handle (Task L8);
9158
+ // `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J) — same
9159
+ // single-prop /_api/edit-css write + per-prop undo record, no new lane.
9160
+ const props = [
9161
+ 'width',
9162
+ 'height',
9163
+ 'left',
9164
+ 'top',
9165
+ 'transform',
9166
+ 'padding-top',
9167
+ 'padding-right',
9168
+ 'padding-bottom',
9169
+ 'padding-left',
9170
+ 'gap',
9171
+ ].filter((p) => typeof patch[p] === 'string' && patch[p]);
9172
+ if (!props.length) return;
9173
+ // Dogfood 2026-07-07 — "resize paddingu nebo gap" was still deselecting on
9174
+ // completion. `applyOptimisticStyle` was posting {id, prop, value} only —
9175
+ // missing artboardId/index — so the canvas-side `apply-style` handler
9176
+ // resolved the UNSCOPED `[data-cd-id]` selector (dom-selection.ts
9177
+ // `resolveSelectionEl`/`scopedCdSelector`), which is a latent multi-
9178
+ // artboard/reused-component miss. Pull the current selection's own
9179
+ // artboardId/index (when it matches `id`) so every optimistic apply +
9180
+ // the belt-and-suspenders reselect below target the SAME instance.
9181
+ const curSel = selectedRef.current;
9182
+ const curOne = Array.isArray(curSel)
9183
+ ? curSel.length === 1
9184
+ ? curSel[0]
9185
+ : null
9186
+ : curSel;
9187
+ const selArtboardId = curOne && curOne.id === id ? (curOne.artboardId ?? null) : null;
9188
+ const selIndex = curOne && curOne.id === id ? (curOne.index ?? 0) : 0;
9189
+ // INV-2 (DDR-105) — arm the reload-suppression window BEFORE the edit-css
9190
+ // writes so the follow-up HMR is skipped. Without this the canvas remounts
9191
+ // and drops the selection (the "resize deselects the element" dogfood bug).
9192
+ for (const p of props) {
9193
+ applyOptimisticStyle({
9194
+ id,
9195
+ artboardId: selArtboardId,
9196
+ index: selIndex,
9197
+ prop: p,
9198
+ value: patch[p],
9199
+ });
9200
+ }
9201
+ const writeProp = (property, value) =>
9202
+ fetch('/_api/edit-css', {
9203
+ method: 'POST',
9204
+ headers: { 'content-type': 'application/json' },
9205
+ body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
9206
+ }).then((r) => r.json().catch(() => ({})));
9207
+ let chain = editApplyChainRef.current.catch(() => {});
9208
+ for (const p of props) {
9209
+ chain = chain.then((prev) => {
9210
+ if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
9211
+ return writeProp(p, patch[p]);
9212
+ });
9213
+ }
9214
+ editApplyChainRef.current = chain
9215
+ .then((last) => {
9216
+ if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
9217
+ // Record one undo entry per written property (each Cmd+Z reverts one).
9218
+ for (const p of props) {
9219
+ recordSourceEdit({ op: 'css', canvas, id, key: p, before: b[p] ?? null, after: patch[p] });
9220
+ }
9221
+ // Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
9222
+ // structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
9223
+ // window should make this a no-op (no reload → the selection was never
9224
+ // lost) — idempotent per `scheduleHaloRestore`'s own doc comment, so it's
9225
+ // safe to always fire, and it's the backstop if suppression ever misses
9226
+ // (a slow FS-watcher round trip past the 1.5s window, for example).
9227
+ scheduleHaloRestore({ id, file: canvas, artboardId: selArtboardId, index: selIndex });
9228
+ })
9229
+ .catch((err) => {
9230
+ console.warn('[resize]', err?.message || err);
9231
+ // Nothing (or only a prefix) persisted — tell the canvas to restore the
9232
+ // pre-drag inline style so a phantom resize doesn't linger.
9233
+ postToActiveCanvas({ dgn: 'resize-failed' });
9234
+ });
9235
+ },
9236
+ [activePath, postToActiveCanvas, recordSourceEdit, applyOptimisticStyle, scheduleHaloRestore]
9237
+ );
9238
+ useEffect(() => {
9239
+ resizeElementRef.current = resizeElement;
9240
+ }, [resizeElement]);
9241
+
9242
+ // feature-element-editing-robustness Stage I — general element structural edits
9243
+ // (delete / insert element / insert artboard) + Stage D4 (artboard resize).
9244
+ // Each is a main-origin-only write the untrusted canvas can only REQUEST over
9245
+ // the dgn:* bus; the shell performs it, pinned to `activePath` (confused-deputy
9246
+ // guard, DDR-054/138). Undo reuses the reorder command's whole-file seq revert
9247
+ // (record-edit op:'reorder') — a structural edit renumbers positional ids, so an
9248
+ // inverse descriptor goes stale (same reason reorder uses the server seq log).
9249
+ const structuralWriteRef = useRef(null);
9250
+ const structuralWrite = useCallback(
9251
+ (route, body, { label, onOk, onFail } = {}) => {
9252
+ if (!activePath) return;
9253
+ const canvas = activePath;
9254
+ editApplyChainRef.current = editApplyChainRef.current
9255
+ .catch(() => {})
9256
+ .then(() =>
9257
+ fetch(route, {
9258
+ method: 'POST',
9259
+ headers: { 'content-type': 'application/json' },
9260
+ body: JSON.stringify({ ...body, canvas }),
9261
+ })
9262
+ .then((r) => r.json().catch(() => ({})))
9263
+ .then((j) => {
9264
+ if (!j.ok) {
9265
+ console.warn(`[${route}]`, j.error || 'failed');
9266
+ onFail?.(j);
9267
+ return;
9268
+ }
9269
+ if (typeof j.seq === 'number') {
9270
+ postToActiveCanvas({
9271
+ dgn: 'record-edit',
9272
+ payload: { op: 'reorder', canvas, seq: j.seq, label: label || 'edit' },
9273
+ });
9274
+ }
9275
+ onOk?.(j, canvas);
9276
+ })
9277
+ .catch((err) => onFail?.({ error: err?.message }))
9278
+ );
9279
+ },
9280
+ [activePath, postToActiveCanvas]
9281
+ );
9282
+ useEffect(() => {
9283
+ structuralWriteRef.current = structuralWrite;
9284
+ }, [structuralWrite]);
9285
+
9286
+ const deleteElementShell = useCallback(
9287
+ (id, idIndex) => {
9288
+ // After the delete lands + HMR reloads, clear the (now-gone) selection.
9289
+ structuralWrite(
9290
+ '/_api/delete-element',
9291
+ { id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
9292
+ { label: 'delete element', onOk: () => postToActiveCanvas({ dgn: 'selection-clear' }) }
9293
+ );
9294
+ },
9295
+ [structuralWrite, postToActiveCanvas]
9296
+ );
9297
+
9298
+ const insertElementShell = useCallback(
9299
+ (refId, position, kind, opts = {}) => {
9300
+ structuralWrite(
9301
+ '/_api/insert-element',
9302
+ {
9303
+ refId,
9304
+ position,
9305
+ kind,
9306
+ src: typeof opts.src === 'string' ? opts.src : undefined,
9307
+ refIndex: Number.isInteger(opts.refIndex) ? opts.refIndex : undefined,
9308
+ },
9309
+ {
9310
+ label: `insert ${kind}`,
9311
+ // Select the new element once the HMR reload lands (its id is stamped
9312
+ // on transpile — best-effort, like reorder's pendingReorderRef).
9313
+ onOk: (j, canvas) => {
9314
+ if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
9315
+ },
9316
+ }
9317
+ );
9318
+ },
9319
+ [structuralWrite]
9320
+ );
9321
+
9322
+ const duplicateElementShell = useCallback(
9323
+ (id, idIndex) => {
9324
+ structuralWrite(
9325
+ '/_api/duplicate-element',
9326
+ { id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
9327
+ {
9328
+ label: 'duplicate element',
9329
+ // Select the copy once the HMR reload lands (best-effort, like insert).
9330
+ onOk: (j, canvas) => {
9331
+ if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
9332
+ },
9333
+ }
9334
+ );
9335
+ },
9336
+ [structuralWrite]
9337
+ );
9338
+
9339
+ // Task L4 — copy-style / paste-style. Captures a selection's AUTHORED inline
9340
+ // styles (not resolved/computed, so DS-token + inherited values aren't baked in)
9341
+ // minus layout/geometry props, then applies them to another element via chained
9342
+ // edit-css writes (one per prop, like resize — N-step undo). Clipboard lives in
9343
+ // the shell so it survives selection + canvas changes.
9344
+ const copiedStyleRef = useRef(null);
9345
+ // Appearance-only: exclude position/size/margin so paste-style copies the LOOK,
9346
+ // not the layout (Figma parity — padding/color/border/shadow/font/… carry over).
9347
+ const PASTE_STYLE_EXCLUDE = useMemo(
9348
+ () =>
9349
+ new Set([
9350
+ 'position',
9351
+ 'top',
9352
+ 'right',
9353
+ 'bottom',
9354
+ 'left',
9355
+ 'inset',
9356
+ 'width',
9357
+ 'height',
9358
+ 'min-width',
9359
+ 'max-width',
9360
+ 'min-height',
9361
+ 'max-height',
9362
+ 'margin',
9363
+ 'margin-top',
9364
+ 'margin-right',
9365
+ 'margin-bottom',
9366
+ 'margin-left',
9367
+ ]),
9368
+ []
9369
+ );
9370
+ const copyStyle = useCallback(() => {
9371
+ const sel = selectedRef.current;
9372
+ const one = Array.isArray(sel) ? (sel.length === 1 ? sel[0] : null) : sel;
9373
+ if (!one) return;
9374
+ const map = {};
9375
+ for (const [k, v] of Object.entries(one.authored || {})) {
9376
+ if (!PASTE_STYLE_EXCLUDE.has(k)) map[k] = v;
9377
+ }
9378
+ for (const [k, v] of Object.entries(one.customStyles || {})) {
9379
+ if (!PASTE_STYLE_EXCLUDE.has(k)) map[k] = v;
9380
+ }
9381
+ copiedStyleRef.current = Object.keys(map).length ? map : null;
9382
+ }, [PASTE_STYLE_EXCLUDE]);
9383
+ const pasteStyle = useCallback(
9384
+ (id) => {
9385
+ const canvas = activePath;
9386
+ const map = copiedStyleRef.current;
9387
+ if (!canvas || !id || !map) return;
9388
+ const entries = Object.entries(map);
9389
+ let chain = editApplyChainRef.current.catch(() => {});
9390
+ for (const [property, value] of entries) {
9391
+ applyOptimisticStyle({ id, prop: property, value }); // preview + arm no-flicker
9392
+ chain = chain.then(() =>
9393
+ fetch('/_api/edit-css', {
9394
+ method: 'POST',
9395
+ headers: { 'content-type': 'application/json' },
9396
+ body: JSON.stringify({ canvas, id, property, value }),
9397
+ })
9398
+ .then((r) => r.json().catch(() => ({})))
9399
+ .then((j) => {
9400
+ if (j.ok)
9401
+ recordSourceEdit({ op: 'css', canvas, id, key: property, before: null, after: value });
9402
+ })
9403
+ .catch(() => {})
9404
+ );
9405
+ }
9406
+ editApplyChainRef.current = chain;
9407
+ },
9408
+ [activePath, applyOptimisticStyle, recordSourceEdit]
9409
+ );
9410
+
9411
+ const insertArtboardShell = useCallback(
9412
+ ({ id, label, width, height }) => {
9413
+ structuralWrite(
9414
+ '/_api/insert-artboard',
9415
+ { id, label, width, height },
9416
+ { label: 'insert artboard' }
9417
+ );
9418
+ },
9419
+ [structuralWrite]
9420
+ );
9421
+
9422
+ // Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
9423
+ // id (server 422s a dup, negligible with a random suffix) + a preset label/dims
9424
+ // and inserts an empty <DCArtboard> after the last one (runtime default-grid
9425
+ // places it; DDR-027). The new frame is then selectable + resizable.
9426
+ const onInsertArtboard = useCallback(
9427
+ (presetKey) => {
9428
+ const p = SCREEN_PRESETS[presetKey];
9429
+ if (!p) return;
9430
+ const id = `s${Math.random().toString(36).slice(2, 8)}`;
9431
+ insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
9432
+ },
9433
+ [insertArtboardShell]
9434
+ );
9435
+
9436
+ const resizeArtboardShell = useCallback(
9437
+ (artboardId, width, height) => {
9438
+ structuralWrite(
9439
+ '/_api/resize-artboard',
9440
+ { artboardId, width, height },
9441
+ {
9442
+ label: 'resize artboard',
9443
+ // Stage D4 — the resize overlay applies a live inline-style preview
9444
+ // during the drag (no HMR yet); on a rejected/failed write, tell it to
9445
+ // restore the pre-drag box so a phantom resize doesn't linger.
9446
+ onFail: () => postToActiveCanvas({ dgn: 'resize-artboard-failed' }),
9447
+ }
9448
+ );
9449
+ },
9450
+ [structuralWrite, postToActiveCanvas]
9451
+ );
9452
+
9453
+ const deleteArtboardShell = useCallback(
9454
+ (artboardId) => {
9455
+ structuralWrite('/_api/delete-artboard', { artboardId }, { label: 'delete artboard' });
9456
+ },
9457
+ [structuralWrite]
9458
+ );
9459
+ // Refs the (stale-closure) onMessage handlers below read.
9460
+ const deleteElementShellRef = useRef(null);
9461
+ const insertElementShellRef = useRef(null);
9462
+ const insertArtboardShellRef = useRef(null);
9463
+ const resizeArtboardShellRef = useRef(null);
9464
+ const deleteArtboardShellRef = useRef(null);
9465
+ const duplicateElementShellRef = useRef(null);
9466
+ const copyStyleRef = useRef(null);
9467
+ const pasteStyleRef = useRef(null);
9468
+ useEffect(() => {
9469
+ deleteElementShellRef.current = deleteElementShell;
9470
+ insertElementShellRef.current = insertElementShell;
9471
+ insertArtboardShellRef.current = insertArtboardShell;
9472
+ resizeArtboardShellRef.current = resizeArtboardShell;
9473
+ deleteArtboardShellRef.current = deleteArtboardShell;
9474
+ duplicateElementShellRef.current = duplicateElementShell;
9475
+ copyStyleRef.current = copyStyle;
9476
+ pasteStyleRef.current = pasteStyle;
9477
+ }, [
9478
+ deleteElementShell,
9479
+ insertElementShell,
9480
+ insertArtboardShell,
9481
+ resizeArtboardShell,
9482
+ deleteArtboardShell,
9483
+ duplicateElementShell,
9484
+ copyStyle,
9485
+ pasteStyle,
9486
+ ]);
9487
+
9488
+ // Shell-level Backspace/Delete guard. CRITICAL: in the Tauri desktop app, an
9489
+ // unhandled Backspace triggers WKWebView back-navigation, which reloads the
9490
+ // WHOLE app to "Starting…" (dogfood crash). When an artboard is selected, focus
9491
+ // sits on the shell (not the canvas iframe), so the in-canvas key handler never
9492
+ // sees the keydown — the shell must catch it. Preventing the default here stops
9493
+ // the back-nav universally; if a single artboard is the selection, also delete
9494
+ // it (Backspace parity with the context menu). Element delete stays in the
9495
+ // canvas iframe (which has focus when an element is selected; its keydown never
9496
+ // reaches this window, so there's no double-handling).
9497
+ useEffect(() => {
9498
+ const onKey = (e) => {
9499
+ if (e.key !== 'Backspace' && e.key !== 'Delete') return;
9500
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
9501
+ const t = e.target;
9502
+ const editable =
9503
+ t &&
9504
+ (t.tagName === 'INPUT' ||
9505
+ t.tagName === 'TEXTAREA' ||
9506
+ t.tagName === 'SELECT' ||
9507
+ t.isContentEditable);
9508
+ if (editable) return;
9509
+ if (!activePath || activePath === SYSTEM_TAB) return;
9510
+ e.preventDefault();
9511
+ const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
9512
+ if (one?.artboardId && !one.id) deleteArtboardShell(one.artboardId);
9513
+ };
9514
+ window.addEventListener('keydown', onKey);
9515
+ return () => window.removeEventListener('keydown', onKey);
9516
+ }, [activePath, selected, deleteArtboardShell]);
9517
+
9518
+ // feature-element-editing-robustness Stage F — AssetPicker request. `req` is
9519
+ // { purpose:'insert-image', refId, position, refIndex } (Insert ▸ Image) or
9520
+ // { purpose:'replace-src', id, before } (Media-section "Replace…"). null = closed.
9521
+ const [assetPickerReq, setAssetPickerReq] = useState(null);
9522
+ const openAssetPickerRef = useRef(null);
9523
+ useEffect(() => {
9524
+ openAssetPickerRef.current = (req) => setAssetPickerReq(req);
9525
+ }, []);
9526
+ const onAssetPicked = useCallback(
9527
+ (pickedPath) => {
9528
+ const req = assetPickerReq;
9529
+ setAssetPickerReq(null);
9530
+ if (!req || !pickedPath) return;
9531
+ // G3 security (DDR-152) — the request captured refId/id against the canvas
9532
+ // that was active when the picker opened; if the user switched canvases
9533
+ // while the modal was up, those ids are meaningless (or worse, collide) on
9534
+ // the now-active canvas. Abort rather than write to the wrong file.
9535
+ if (req.canvas && req.canvas !== activePath) {
9536
+ console.warn('[asset-picker] active canvas changed since request — aborting');
9537
+ return;
9538
+ }
9539
+ if (req.purpose === 'insert-image') {
9540
+ insertElementShell(req.refId, req.position || 'after', 'image', {
9541
+ src: pickedPath,
9542
+ refIndex: req.refIndex,
9543
+ });
9544
+ return;
9545
+ }
9546
+ if (req.purpose === 'replace-src') {
9547
+ // Re-point an authored <img>/<video> src via /_api/edit-attr (+ undo).
9548
+ const canvas = activePath;
9549
+ if (!canvas || !req.id) return;
9550
+ editApplyChainRef.current = editApplyChainRef.current
9551
+ .catch(() => {})
9552
+ .then(() =>
9553
+ fetch('/_api/edit-attr', {
9554
+ method: 'POST',
9555
+ headers: { 'content-type': 'application/json' },
9556
+ body: JSON.stringify({ canvas, id: req.id, attr: 'src', value: pickedPath }),
9557
+ })
9558
+ .then((r) => r.json().catch(() => ({})))
9559
+ .then((j) => {
9560
+ if (j.ok) {
9561
+ recordSourceEdit({
9562
+ op: 'attr',
9563
+ canvas,
9564
+ id: req.id,
9565
+ key: 'src',
9566
+ before: req.before ?? null,
9567
+ after: pickedPath,
9568
+ });
9569
+ } else {
9570
+ console.warn('[replace-src]', j.error || 'failed');
9571
+ }
9572
+ })
9573
+ .catch(() => {})
9574
+ );
9575
+ return;
9576
+ }
9577
+ if (req.purpose === 'replace-annotation-media') {
9578
+ // Stage F3 — relay the picked path DOWN to the canvas; the annotation
9579
+ // model owns its own strokes + persistence + undo (`commitStrokes`), so
9580
+ // unlike `replace-src` the shell performs no write of its own here.
9581
+ postToActiveCanvas({ dgn: 'replace-annotation-media', id: req.id, path: pickedPath });
9582
+ }
9583
+ },
9584
+ [assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
9585
+ );
9586
+ // Media-section "Replace…" (CssKnobs) → open the picker in replace mode with
9587
+ // the element's current src as the undo before-value (captured from the
9588
+ // Selection's `attrs.src`, not the resolved URL).
9589
+ const onReplaceMedia = useCallback(
9590
+ (elSel) => {
9591
+ if (!elSel?.id) return;
9592
+ setAssetPickerReq({
9593
+ purpose: 'replace-src',
9594
+ canvas: activePath,
9595
+ id: elSel.id,
9596
+ before: elSel.attrs?.src ?? null,
9597
+ });
9598
+ },
9599
+ [activePath]
9600
+ );
9601
+
8187
9602
  const resolveComment = useCallback((id) => {
8188
9603
  wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
8189
9604
  }, []);
@@ -8653,6 +10068,9 @@ function App() {
8653
10068
  inspectorOpen={inspectorOpen}
8654
10069
  inspectorTab={inspectorTab}
8655
10070
  onToggleInspector={() => toggleRightPanel('inspector')}
10071
+ autoOpenInspector={autoOpenInspector}
10072
+ onToggleAutoOpenInspector={() => setAutoOpenInspector((v) => !v)}
10073
+ onInsertArtboard={onInsertArtboard}
8656
10074
  timelineOpen={timelineOpen}
8657
10075
  onToggleTimeline={toggleTimeline}
8658
10076
  hasComps={activeComps.length > 0}
@@ -8810,8 +10228,12 @@ function App() {
8810
10228
  onClose={() => setInspectorOpen(false)}
8811
10229
  onOptimistic={applyOptimisticStyle}
8812
10230
  onRecordEdit={recordSourceEdit}
10231
+ onReplaceMedia={onReplaceMedia}
10232
+ onResizeArtboard={resizeArtboardShell}
10233
+ editScope={editScope}
8813
10234
  onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
8814
10235
  layersTree={layersTree}
10236
+ canvasFile={activePath}
8815
10237
  onSelectLayer={(n) =>
8816
10238
  postToActiveCanvas({
8817
10239
  dgn: 'select-by-id',
@@ -9266,6 +10688,13 @@ function App() {
9266
10688
  onClose={() => setExportDialog(null)}
9267
10689
  />
9268
10690
  )}
10691
+ {assetPickerReq && (
10692
+ <AssetPicker
10693
+ designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
10694
+ onPick={onAssetPicked}
10695
+ onClose={() => setAssetPickerReq(null)}
10696
+ />
10697
+ )}
9269
10698
  {diffTarget && (
9270
10699
  <DiffView
9271
10700
  target={diffTarget}