@1agh/maude 0.46.0 → 0.48.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 (81) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  3. package/apps/studio/acp/bridge.ts +121 -6
  4. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  5. package/apps/studio/annotations-layer.tsx +42 -0
  6. package/apps/studio/api.ts +417 -2
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  13. package/apps/studio/bin/curl-local.sh +28 -0
  14. package/apps/studio/build.ts +1 -1
  15. package/apps/studio/canvas-cursors.ts +6 -0
  16. package/apps/studio/canvas-edit.ts +678 -11
  17. package/apps/studio/canvas-icons.tsx +13 -0
  18. package/apps/studio/canvas-lib.tsx +3 -0
  19. package/apps/studio/canvas-shell.tsx +646 -26
  20. package/apps/studio/client/app.jsx +898 -52
  21. package/apps/studio/client/export-center.jsx +7 -6
  22. package/apps/studio/client/github.js +11 -4
  23. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  24. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  25. package/apps/studio/contextual-toolbar.tsx +5 -3
  26. package/apps/studio/dist/client.bundle.js +1103 -1103
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  29. package/apps/studio/dist/styles.css +1 -1
  30. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  31. package/apps/studio/exporters/pdf.ts +35 -1
  32. package/apps/studio/git/service.ts +4 -1
  33. package/apps/studio/grid-track-handles.ts +179 -0
  34. package/apps/studio/handoff.ts +35 -0
  35. package/apps/studio/http.ts +118 -0
  36. package/apps/studio/input-router.tsx +73 -17
  37. package/apps/studio/paths.ts +37 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  39. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  40. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  41. package/apps/studio/test/browse-posture.test.tsx +107 -0
  42. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  43. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  45. package/apps/studio/test/comment-mount.test.ts +2 -1
  46. package/apps/studio/test/component-map.test.ts +48 -0
  47. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  48. package/apps/studio/test/detach-component.test.ts +94 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  50. package/apps/studio/test/element-structural-api.test.ts +74 -0
  51. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  52. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  53. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  54. package/apps/studio/test/handoff.test.ts +48 -0
  55. package/apps/studio/test/input-router.test.ts +82 -8
  56. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  57. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  58. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  59. package/apps/studio/tool-palette.tsx +3 -1
  60. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  61. package/apps/studio/use-element-resize.tsx +3 -1
  62. package/apps/studio/use-grid-track-handles.tsx +364 -0
  63. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  64. package/apps/studio/use-tool-mode.tsx +30 -3
  65. package/apps/studio/web-overlay-content.tsx +52 -0
  66. package/apps/studio/whats-new.json +27 -0
  67. package/cli/bin/maude.mjs +1 -0
  68. package/cli/commands/design.mjs +16 -0
  69. package/cli/commands/init.mjs +80 -3
  70. package/cli/commands/kg.mjs +368 -0
  71. package/cli/commands/kg.test.mjs +118 -0
  72. package/cli/lib/ddr-to-kgai.mjs +648 -0
  73. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  74. package/cli/lib/flow-design-integration.test.mjs +2 -2
  75. package/cli/lib/gitignore-block.mjs +4 -0
  76. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  77. package/package.json +8 -8
  78. package/plugins/design/dependencies.json +17 -0
  79. package/plugins/design/templates/_shell.html +4 -0
  80. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  81. package/plugins/flow/dependencies.json +17 -0
@@ -12,6 +12,10 @@ 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
+ // feature-3-web-artboards T5 — the single source (grid-track-handles.ts) for
16
+ // the Inspector's Grid-section track parser/serializer, shared with the
17
+ // on-canvas gutter-drag overlay so both edit the SAME track-list shape.
18
+ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-track-handles.ts';
15
19
  // feature-2-print-artboards T2 — the single source (print/units.ts) for the
16
20
  // Inspector's paper-preset picker, same "pull only pure math/data" shape as
17
21
  // canvas-cursors.ts above.
@@ -99,6 +103,11 @@ const MINIMAP_STORE = 'mdcc-minimap-visible';
99
103
  const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
100
104
  const ANNOT_STORE = 'mdcc-annotations-visible';
101
105
  const AUTOOPEN_STORE = 'maude-auto-open-inspector';
106
+ // DDR-185 security addendum — floor between OS notifications for a NEW
107
+ // permission/elicitation request, so a burst of distinct pending approvals
108
+ // (capped at 15 total server-side, DDR-179/180) reads as one attention-
109
+ // getting ping instead of a rapid-fire flood. The in-app badge is unaffected.
110
+ const ATTENTION_NOTIFY_COOLDOWN_MS = 30_000;
102
111
 
103
112
  // feature-unified-settings-modal — the Settings modal's view prefs are ALSO
104
113
  // persisted to disk (~/.config/maude/prefs.json via /_api/ui-prefs), so they
@@ -173,6 +182,20 @@ const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
173
182
  // the bundle is consumed in a context that hasn't run the build.
174
183
  const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
175
184
 
185
+ // Best-effort OS notification — shared by handleAssistantFinished and
186
+ // handleAssistantAttention below (identical support/permission check +
187
+ // try/catch, only the title/body differ). The in-app badge stays the
188
+ // reliable signal if this silently fails for any reason.
189
+ function notifyDesktop(title, body) {
190
+ try {
191
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
192
+ new Notification(title, { body });
193
+ }
194
+ } catch {
195
+ /* best-effort — the in-app badge is the reliable signal */
196
+ }
197
+ }
198
+
176
199
  function readInitialTheme() {
177
200
  if (typeof window === 'undefined') return 'dark';
178
201
  try {
@@ -252,6 +275,58 @@ function shellToast(message, ok = false) {
252
275
  }, 3200);
253
276
  }
254
277
 
278
+ // feature-4 (browse/move split, DDR-187) — one-time first-run teaching hint.
279
+ // The boot default is `browse` (the mock is ALIVE — buttons click), so the
280
+ // Figma muscle-memory gesture (plain click = select) does nothing until V is
281
+ // pressed. Surface that ONCE, gated by a localStorage marker (same family as
282
+ // `mdcc-whatsnew-seen`). Auto-dismisses; a V press (learned) clears it early.
283
+ const BROWSE_HINT_SEEN = 'maude-browse-hint-seen';
284
+ function browseFirstRunHint() {
285
+ if (typeof document === 'undefined' || typeof localStorage === 'undefined') return;
286
+ try {
287
+ if (localStorage.getItem(BROWSE_HINT_SEEN) === '1') return;
288
+ } catch {
289
+ return;
290
+ }
291
+ if (document.getElementById('st-browse-hint')) return;
292
+ const el = document.createElement('div');
293
+ el.id = 'st-browse-hint';
294
+ el.setAttribute('role', 'status');
295
+ el.style.cssText =
296
+ 'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:81;' +
297
+ 'display:flex;align-items:center;gap:10px;max-width:520px;padding:10px 14px;border-radius:10px;' +
298
+ 'font:12px/1.5 var(--font-ui,system-ui,sans-serif);background:var(--surface-2,#1b1e24);' +
299
+ 'color:var(--text-1,#e7eaf0);border:1px solid var(--border-1,#333a45);' +
300
+ 'box-shadow:0 10px 34px rgba(0,0,0,.42);opacity:0;transition:opacity 160ms ease;';
301
+ el.innerHTML =
302
+ '<span>Your mock is <strong>live</strong> — click things to try it. ' +
303
+ 'Press <kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
304
+ 'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd> ' +
305
+ 'to select &amp; edit like Figma.</span>' +
306
+ '<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
307
+ 'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
308
+ const dismiss = () => {
309
+ try {
310
+ localStorage.setItem(BROWSE_HINT_SEEN, '1');
311
+ } catch {
312
+ /* private mode */
313
+ }
314
+ el.style.opacity = '0';
315
+ setTimeout(() => el.remove(), 200);
316
+ document.removeEventListener('keydown', onV, true);
317
+ };
318
+ const onV = (e) => {
319
+ if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) dismiss();
320
+ };
321
+ el.querySelector('button')?.addEventListener('click', dismiss);
322
+ document.addEventListener('keydown', onV, true);
323
+ document.body.appendChild(el);
324
+ requestAnimationFrame(() => {
325
+ el.style.opacity = '1';
326
+ });
327
+ setTimeout(dismiss, 9000);
328
+ }
329
+
255
330
  // Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
256
331
  // Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
257
332
  // the file type stays unambiguous.
@@ -431,6 +506,19 @@ const STICONS = {
431
506
  </>
432
507
  ),
433
508
  folder: <path d="M2 4.5h4l1.3 1.5H14V13H2z" />,
509
+ // feature-4 T7b — Layers padlock (closed / open).
510
+ lock: (
511
+ <>
512
+ <rect x="3.5" y="7" width="9" height="6.5" rx="1" />
513
+ <path d="M5.5 7V5a2.5 2.5 0 015 0v2" />
514
+ </>
515
+ ),
516
+ unlock: (
517
+ <>
518
+ <rect x="3.5" y="7" width="9" height="6.5" rx="1" />
519
+ <path d="M5.5 7V5a2.5 2.5 0 015-.7" />
520
+ </>
521
+ ),
434
522
  search: (
435
523
  <>
436
524
  <circle cx="7" cy="7" r="4" />
@@ -3031,7 +3119,10 @@ function ToolsDropdown({ onAction, onClose }) {
3031
3119
  onAction={onAction}
3032
3120
  onClose={onClose}
3033
3121
  items={[
3034
- { id: 'move', label: 'Move', shortcut: 'V' },
3122
+ // feature-4 (browse/move split) — Browse is the boot default (mock is
3123
+ // alive); Move (V) is the select tool.
3124
+ { id: 'browse', label: 'Browse (interact)', shortcut: '' },
3125
+ { id: 'move', label: 'Select', shortcut: 'V' },
3035
3126
  { id: 'hand', label: 'Hand', shortcut: 'H' },
3036
3127
  { id: 'comment', label: 'Comment', shortcut: 'C' },
3037
3128
  { id: 'pen', label: 'Pen', shortcut: 'B' },
@@ -4323,6 +4414,9 @@ function SyncBanner({ status }) {
4323
4414
  // critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
4324
4415
 
4325
4416
  const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
4417
+ // feature-3-web-artboards T5 — per-track unit ladder for the Grid section's
4418
+ // UnitSelect (the stub's own list: px/%/fr/em/auto/min-content/max-content).
4419
+ const GRID_TRACK_UNITS = ['px', '%', 'fr', 'em', 'auto', 'min-content', 'max-content'];
4326
4420
  const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
4327
4421
  const CSS_FLEX_WRAP = ['nowrap', 'wrap', 'wrap-reverse'];
4328
4422
  const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
@@ -5129,6 +5223,98 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
5129
5223
  );
5130
5224
  }
5131
5225
 
5226
+ // feature-3-web-artboards T5 (absorbed feature-grid-track-editor stub) — the
5227
+ // CssKnobs "Grid" section's track-list editor: one row per track (a
5228
+ // NumberField + UnitSelect for a numeric px/%/fr/em track, a bare Select for
5229
+ // a keyword auto/min-content/max-content track since it carries no numeric
5230
+ // value), + add/remove. `tracks`/`onChange` are the parsed/serialized shape
5231
+ // from `grid-track-handles.ts` — the SAME module the on-canvas gutter-drag
5232
+ // overlay uses, so the Inspector and the drag handles never disagree.
5233
+ function GridTracksEditor({ label, tracks, editable, onChange }) {
5234
+ const setUnit = (idx, unit) => {
5235
+ const cur = tracks[idx];
5236
+ onChange(
5237
+ tracks.map((t, i) =>
5238
+ i === idx
5239
+ ? GRID_KEYWORD_UNITS.has(unit)
5240
+ ? { value: 0, unit }
5241
+ : { value: cur.value || 1, unit }
5242
+ : t
5243
+ )
5244
+ );
5245
+ };
5246
+ const setValue = (idx, value) => {
5247
+ onChange(tracks.map((t, i) => (i === idx ? { ...t, value } : t)));
5248
+ };
5249
+ const addTrack = () => onChange([...tracks, { value: 1, unit: 'fr' }]);
5250
+ const removeTrack = (idx) => onChange(tracks.filter((_, i) => i !== idx));
5251
+ return (
5252
+ <div className="st-cp-gridtracks">
5253
+ <div className="st-cp-gridtracks-hd">
5254
+ <span className="st-cp-gridtracks-label">{label}</span>
5255
+ <button
5256
+ type="button"
5257
+ className="st-btn st-cp-gridtracks-add"
5258
+ disabled={!editable}
5259
+ onClick={addTrack}
5260
+ >
5261
+ + Track
5262
+ </button>
5263
+ </div>
5264
+ {tracks.length === 0 ? (
5265
+ <div className="st-cp-note">
5266
+ No explicit tracks — add one to start defining {label.toLowerCase()}.
5267
+ </div>
5268
+ ) : (
5269
+ tracks.map((t, idx) => {
5270
+ const isKeyword = GRID_KEYWORD_UNITS.has(t.unit);
5271
+ return (
5272
+ // biome-ignore lint/suspicious/noArrayIndexKey: tracks have no stable id;
5273
+ // add/remove/reorder all rebuild the array positionally, same as every
5274
+ // other index-keyed list in this file (e.g. the Layers tree rows).
5275
+ <div className="st-cp-gridtrack-row" key={idx}>
5276
+ {isKeyword ? (
5277
+ <Select
5278
+ value={t.unit}
5279
+ ariaLabel={`${label} track ${idx + 1}`}
5280
+ options={GRID_TRACK_UNITS.map((u) => ({ value: u, label: u }))}
5281
+ onChange={(u) => setUnit(idx, u)}
5282
+ />
5283
+ ) : (
5284
+ <NumberField
5285
+ value={t.value}
5286
+ min={t.unit === 'fr' ? 0.1 : 0}
5287
+ step={t.unit === 'fr' ? 0.1 : 1}
5288
+ ariaLabel={`${label} track ${idx + 1} value`}
5289
+ lead={String(idx + 1)}
5290
+ unitSlot={
5291
+ <UnitSelect
5292
+ value={t.unit}
5293
+ units={GRID_TRACK_UNITS}
5294
+ ariaLabel={`${label} track ${idx + 1} unit`}
5295
+ onChange={(u) => setUnit(idx, u)}
5296
+ />
5297
+ }
5298
+ onCommit={(n) => setValue(idx, n)}
5299
+ />
5300
+ )}
5301
+ <button
5302
+ type="button"
5303
+ className="st-btn st-cp-gridtrack-remove"
5304
+ aria-label={`remove ${label} track ${idx + 1}`}
5305
+ disabled={!editable || tracks.length <= 1}
5306
+ onClick={() => removeTrack(idx)}
5307
+ >
5308
+ ×
5309
+ </button>
5310
+ </div>
5311
+ );
5312
+ })
5313
+ )}
5314
+ </div>
5315
+ );
5316
+ }
5317
+
5132
5318
  function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
5133
5319
  const editable = !!el.id;
5134
5320
  const computed = el.computed || {};
@@ -5475,6 +5661,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5475
5661
  // Props each section owns — drives the per-section "reset section" affordance.
5476
5662
  const SECTION_PROPS = {
5477
5663
  Layout: ['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap'],
5664
+ // feature-3-web-artboards T5 — Grid track editor + cell placement, so the
5665
+ // section-reset affordance clears them like every other section.
5666
+ Grid: ['grid-template-columns', 'grid-template-rows'],
5667
+ 'Grid item': ['grid-column', 'grid-row'],
5478
5668
  // feature-element-editing-robustness Stage B — promoted DDR-104 OUT-list.
5479
5669
  Position: ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
5480
5670
  Typography: [
@@ -6372,6 +6562,54 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6372
6562
  })()
6373
6563
  )}
6374
6564
 
6565
+ {(() => {
6566
+ // feature-3-web-artboards T5 (absorbed feature-grid-track-editor
6567
+ // stub) — Grid section, only when the element IS a grid container.
6568
+ // Track parse/serialize is the SAME grid-track-handles.ts module the
6569
+ // on-canvas gutter-drag overlay uses, so the Inspector and the drag
6570
+ // handles never disagree about track shape.
6571
+ const disp = (authored.display || cssHint(computed.display) || '').trim();
6572
+ const isGrid = disp === 'grid' || disp === 'inline-grid';
6573
+ if (!isGrid) return null;
6574
+ const colsRaw = authored['grid-template-columns'] || '';
6575
+ const rowsRaw = authored['grid-template-rows'] || '';
6576
+ const cols = parseTrackList(colsRaw);
6577
+ const rows = parseTrackList(rowsRaw);
6578
+ const colsEditable = cols.length > 0 || !colsRaw.trim();
6579
+ const rowsEditable = rows.length > 0 || !rowsRaw.trim();
6580
+ return sec(
6581
+ 'Grid',
6582
+ <>
6583
+ {colsEditable ? (
6584
+ <GridTracksEditor
6585
+ label="Columns"
6586
+ tracks={cols}
6587
+ editable={editable}
6588
+ onChange={(next) => commit('grid-template-columns', serializeTrackList(next))}
6589
+ />
6590
+ ) : (
6591
+ <div className="st-cp-note">
6592
+ Columns use <code>{colsRaw}</code> — not editable as tracks here (repeat()/
6593
+ minmax()/subgrid); use /design:edit for these.
6594
+ </div>
6595
+ )}
6596
+ {rowsEditable ? (
6597
+ <GridTracksEditor
6598
+ label="Rows"
6599
+ tracks={rows}
6600
+ editable={editable}
6601
+ onChange={(next) => commit('grid-template-rows', serializeTrackList(next))}
6602
+ />
6603
+ ) : (
6604
+ <div className="st-cp-note">
6605
+ Rows use <code>{rowsRaw}</code> — not editable as tracks here (repeat()/minmax()/
6606
+ subgrid); use /design:edit for these.
6607
+ </div>
6608
+ )}
6609
+ </>
6610
+ );
6611
+ })()}
6612
+
6375
6613
  {sec(
6376
6614
  'Position',
6377
6615
  <>
@@ -6395,6 +6633,18 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6395
6633
  </>
6396
6634
  )}
6397
6635
 
6636
+ {(el.parentDisplay === 'grid' || el.parentDisplay === 'inline-grid') &&
6637
+ sec(
6638
+ 'Grid item',
6639
+ <>
6640
+ <div className="st-cp-note">
6641
+ Manual cell placement — <code>start / end</code> or <code>start / span N</code>.
6642
+ </div>
6643
+ {row('grid-column', text('grid-column'))}
6644
+ {row('grid-row', text('grid-row'))}
6645
+ </>
6646
+ )}
6647
+
6398
6648
  {sec(
6399
6649
  'Typography',
6400
6650
  <>
@@ -6707,6 +6957,8 @@ const LAYER_TYPE_ICON = {
6707
6957
  list: 'list',
6708
6958
  nav: 'layers',
6709
6959
  box: 'box',
6960
+ // feature-4 T7 — synthetic group row (unstamped wrapper with stamped kids).
6961
+ group: 'folder',
6710
6962
  };
6711
6963
 
6712
6964
 
@@ -6773,21 +7025,52 @@ function LayerRow({
6773
7025
  onReorder,
6774
7026
  onRowPointerDown,
6775
7027
  dragState,
7028
+ // feature-4 T7a/b/c — component map (purple ◇/◆), locked keys + toggle,
7029
+ // dblclick rename (writes data-dc-element via edit-attr).
7030
+ componentMap,
7031
+ lockedKeys,
7032
+ onToggleLock,
7033
+ onRename,
6776
7034
  }) {
6777
7035
  const key = `${node.id}:${node.index}`;
6778
7036
  const hasKids = node.children && node.children.length > 0;
6779
7037
  const isCollapsed = collapsed.has(key);
7038
+ // feature-4 T7 — a synthetic group row (unstamped wrapper) is a pure
7039
+ // structural stand-in: NOT selectable (no data-cd-id to address in source),
7040
+ // NOT draggable, NO eye toggle. Clicking it just expands/collapses.
7041
+ const isSynthetic = !!node.synthetic;
7042
+ // feature-4 T7a — purple instance rows: this element renders through an
7043
+ // instantiated component. `root` = the component's own frame root (◆).
7044
+ const inst = !isSynthetic && componentMap ? componentMap[node.id] : null;
7045
+ // feature-4 T7b — locked layers can't be selected/dragged on the canvas.
7046
+ const isLocked = !isSynthetic && !!lockedKeys?.has(key);
7047
+ // feature-4 T7c — inline rename (dblclick the label).
7048
+ const [renaming, setRenaming] = useState(false);
7049
+ const [renameDraft, setRenameDraft] = useState('');
6780
7050
  // Match the specific INSTANCE (id + occurrence index), not every element that
6781
7051
  // shares this source id — otherwise a `.map`ed element highlights all its
6782
7052
  // clones at once. Fall back to id-only when the selection carries no index.
6783
7053
  const isSel =
6784
- node.id === selectedId && (selectedIndex == null || node.index === selectedIndex);
7054
+ !isSynthetic &&
7055
+ node.id === selectedId &&
7056
+ (selectedIndex == null || node.index === selectedIndex);
6785
7057
  const isHidden = hiddenOverride?.has(key) ? hiddenOverride.get(key) : !!node.hidden;
6786
7058
  // A shared data-cd-id (reused component instance) IS reorderable now — the
6787
7059
  // server maps the occurrence index to the parent <Component> usage — so these
6788
7060
  // are no longer greyed/blocked. A `.map()`ed single-usage element still can't
6789
- // split; that move is refused server-side and reverts.
6790
- const canDrag = !!onReorder;
7061
+ // split; that move is refused server-side and reverts. A LOCKED row never
7062
+ // drags (feature-4 T7b).
7063
+ const canDrag = !!onReorder && !isSynthetic && !isLocked;
7064
+ const commitRename = () => {
7065
+ setRenaming(false);
7066
+ const v = renameDraft
7067
+ .trim()
7068
+ .toLowerCase()
7069
+ .replace(/\s+/g, '-')
7070
+ .replace(/[^a-z0-9-]/g, '');
7071
+ if (!v || v === (node.dcElement ?? '')) return;
7072
+ onRename?.(node, v);
7073
+ };
6791
7074
  // Phase 12.1 — the row being dragged FLOATS with the cursor (same model as the
6792
7075
  // in-canvas drag): a transform follows the pointer, its layout box stays
6793
7076
  // reserved (empty slot at the origin), and pointer-events:none lets the row
@@ -6809,26 +7092,46 @@ function LayerRow({
6809
7092
  <>
6810
7093
  <div
6811
7094
  className={
6812
- 'st-layer st-layer--row' + (isSel ? ' is-sel' : '') + (isHidden ? ' is-hidden' : '')
7095
+ 'st-layer st-layer--row' +
7096
+ (isSel ? ' is-sel' : '') +
7097
+ (isHidden ? ' is-hidden' : '') +
7098
+ (isSynthetic ? ' is-group' : '') +
7099
+ (inst ? ' is-instance' : '') +
7100
+ (isLocked ? ' is-locked' : '')
6813
7101
  }
6814
7102
  style={{ paddingLeft: 6 + depth * 14, ...dragStyle }}
6815
7103
  role="treeitem"
6816
- aria-selected={isSel}
7104
+ aria-selected={isSynthetic ? undefined : isSel}
6817
7105
  aria-expanded={hasKids ? !isCollapsed : undefined}
6818
7106
  aria-grabbed={canDrag ? isDragging : undefined}
6819
7107
  tabIndex={0}
6820
- title={`${node.tag} · ${node.type}`}
7108
+ title={
7109
+ isSynthetic
7110
+ ? `${node.tag} · group (unstamped wrapper)`
7111
+ : `${node.tag} · ${node.type}${onRename ? ' · F2 to rename' : ''}`
7112
+ }
6821
7113
  data-layer-key={key}
6822
- onClick={() => onSelect(node)}
6823
- onMouseEnter={() => onHover(node)}
7114
+ onClick={() => (isSynthetic ? hasKids && onToggle(key) : onSelect(node))}
7115
+ onMouseEnter={() => !isSynthetic && onHover(node)}
6824
7116
  onMouseLeave={() => onHover(null)}
6825
7117
  onPointerDown={canDrag ? (e) => onRowPointerDown(e, node, key) : undefined}
6826
7118
  onKeyDown={(e) => {
6827
- // Enter/Space select; ↑/↓ (+ modifiers) are handled at the tree
6828
- // container (selection-driven — survives the HMR re-render).
7119
+ // Enter/Space select (or, for a synthetic group, toggle); ↑/↓ (+
7120
+ // modifiers) are handled at the tree container (selection-driven —
7121
+ // survives the HMR re-render).
6829
7122
  if (e.key === 'Enter' || e.key === ' ') {
6830
7123
  e.preventDefault();
6831
- onSelect(node);
7124
+ if (isSynthetic) hasKids && onToggle(key);
7125
+ else onSelect(node);
7126
+ } else if (e.key === 'F2' && onRename && !isSynthetic) {
7127
+ // a11y fix (review fan-out, 2026-07-21) — dblclick was the ONLY
7128
+ // entry point into rename, a keyboard-only user had no way to
7129
+ // reach it at all (WCAG 2.1.1). F2 is the conventional rename key
7130
+ // (Explorer/Finder/most tree UIs); mirrors the dblclick handler's
7131
+ // own pre-fill.
7132
+ e.preventDefault();
7133
+ setRenameDraft(node.dcElement || node.label || '');
7134
+ setRenaming(true);
6832
7135
  }
6833
7136
  }}
6834
7137
  >
@@ -6847,10 +7150,67 @@ function LayerRow({
6847
7150
  ) : (
6848
7151
  <span className="st-layer-caret" aria-hidden="true" />
6849
7152
  )}
6850
- <StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
6851
- <span className="st-layer-label">{node.label}</span>
6852
- <span className="st-layer-type">{node.type}</span>
6853
- {onToggleVisibility ? (
7153
+ {inst ? (
7154
+ // feature-4 T7a — Figma vocabulary: ◆ the component's own frame root,
7155
+ // ◇ an inner member of an instance. Purple via .is-instance CSS.
7156
+ <span className="st-layer-inst-glyph" aria-hidden="true">
7157
+ {inst.root ? '◆' : '◇'}
7158
+ </span>
7159
+ ) : (
7160
+ <StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
7161
+ )}
7162
+ {renaming ? (
7163
+ <input
7164
+ className="st-layer-rename"
7165
+ value={renameDraft}
7166
+ autoFocus
7167
+ aria-label={`Rename ${node.label}`}
7168
+ onFocus={(e) => e.target.select()}
7169
+ onChange={(e) => setRenameDraft(e.target.value)}
7170
+ onClick={(e) => e.stopPropagation()}
7171
+ onPointerDown={(e) => e.stopPropagation()}
7172
+ onBlur={commitRename}
7173
+ onKeyDown={(e) => {
7174
+ e.stopPropagation();
7175
+ if (e.key === 'Enter') commitRename();
7176
+ else if (e.key === 'Escape') setRenaming(false);
7177
+ }}
7178
+ />
7179
+ ) : (
7180
+ <span
7181
+ className="st-layer-label"
7182
+ onDoubleClick={
7183
+ onRename && !isSynthetic
7184
+ ? (e) => {
7185
+ // feature-4 T7c — dblclick rename. Pre-fill from the raw
7186
+ // data-dc-element (kebab) when set, else the display label.
7187
+ e.stopPropagation();
7188
+ setRenameDraft(node.dcElement || node.label || '');
7189
+ setRenaming(true);
7190
+ }
7191
+ : undefined
7192
+ }
7193
+ >
7194
+ {node.label}
7195
+ </span>
7196
+ )}
7197
+ <span className="st-layer-type">{inst ? inst.component : node.type}</span>
7198
+ {onToggleLock && !isSynthetic ? (
7199
+ <button
7200
+ type="button"
7201
+ className="st-layer-lock"
7202
+ aria-label={isLocked ? `Unlock ${node.label}` : `Lock ${node.label}`}
7203
+ aria-pressed={isLocked}
7204
+ title={isLocked ? 'Unlock' : 'Lock'}
7205
+ onClick={(e) => {
7206
+ e.stopPropagation();
7207
+ onToggleLock(key);
7208
+ }}
7209
+ >
7210
+ <StIcon name={isLocked ? 'lock' : 'unlock'} size={12} />
7211
+ </button>
7212
+ ) : null}
7213
+ {onToggleVisibility && !isSynthetic ? (
6854
7214
  <button
6855
7215
  type="button"
6856
7216
  className="st-layer-eye"
@@ -6883,6 +7243,10 @@ function LayerRow({
6883
7243
  onReorder={onReorder}
6884
7244
  onRowPointerDown={onRowPointerDown}
6885
7245
  dragState={dragState}
7246
+ componentMap={componentMap}
7247
+ lockedKeys={lockedKeys}
7248
+ onToggleLock={onToggleLock}
7249
+ onRename={onRename}
6886
7250
  />
6887
7251
  ))
6888
7252
  : null}
@@ -6999,6 +7363,7 @@ function ArtboardKnobs({
6999
7363
  onSetArtboardStyle,
7000
7364
  onSetArtboardKind,
7001
7365
  onSetArtboardPrint,
7366
+ onDuplicateArtboard,
7002
7367
  }) {
7003
7368
  const artboardId = resolveArtboardIdFromSelection(el);
7004
7369
  // Dogfood (artboard panel ↔ shared inspector controls) — the panel now uses
@@ -7053,6 +7418,12 @@ function ArtboardKnobs({
7053
7418
  const activePreset = Object.entries(SCREEN_PRESETS).find(
7054
7419
  ([, p]) => p.width === w && p.height === h
7055
7420
  )?.[0];
7421
+ // feature-3-web-artboards T2 — a web artboard hugs height (Design Decision
7422
+ // 1), so its `h` rarely equals a preset's device height even when the
7423
+ // artboard genuinely represents that breakpoint. Match by WIDTH alone so
7424
+ // the picker still shows "Desktop" selected after the hugged height
7425
+ // settles to the content's real size.
7426
+ const activeWidthPreset = Object.entries(SCREEN_PRESETS).find(([, p]) => p.width === w)?.[0];
7056
7427
  // Hug default (artboard "hug height") — current mode + the "more settings"
7057
7428
  // (background/padding/layout/gap) read off the SAME generic `attrs` escape
7058
7429
  // hatch dom-selection.ts already scrapes for every selection (the `data-dc-*`
@@ -7079,31 +7450,38 @@ function ArtboardKnobs({
7079
7450
  }
7080
7451
  const setKind = (nextKind) => {
7081
7452
  if (!artboardId) return;
7082
- // Picking "Digital" clears the explicit prop back to the implicit
7083
- // default (applySetArtboardKind's `kind: null` path) rather than writing
7084
- // a redundant `kind="digital"`.
7085
- onSetArtboardKind?.(artboardId, nextKind === 'digital' ? null : nextKind);
7086
7453
  // Dogfood fix — switching TO "print" with no print prop yet left the
7087
- // artboard in a half-configured state: the guides overlay renders
7088
- // nothing (print-overlay-content.tsx requires the prop) and the PDF
7089
- // exporter has no bleed to read, even though the Kind chip already
7090
- // shows "print". Seed a default A4 print prop + its resolved px size in
7091
- // the SAME gesture, mirroring the "+ Artboard: print preset" flow —
7092
- // switching a DIGITAL artboard to print should never require a second,
7093
- // easy-to-miss step in the (still-collapsed) Print section below.
7454
+ // artboard half-configured (no guides geometry, no bleed for the PDF
7455
+ // exporter). Seed a default A4 print prop + its resolved px size in the
7456
+ // SAME gesture. Round 6: the seed is PASSED THROUGH the kind flow (not
7457
+ // written immediately) so the freeze-convert the canvas may enqueue first
7458
+ // lands BEFORE the A4 resize — resizing pre-freeze moved elements around
7459
+ // while the confirm dialog was still open.
7460
+ let seedPrint = null;
7094
7461
  if (nextKind === 'print' && !print) {
7095
7462
  const defaults = { paper: 'a4' };
7096
- let resolved;
7097
7463
  try {
7098
- resolved = resolvePrintArtboard(defaults);
7464
+ const resolved = resolvePrintArtboard(defaults);
7465
+ if (resolved) {
7466
+ seedPrint = { defaults, widthPx: resolved.widthPx, heightPx: resolved.heightPx };
7467
+ }
7099
7468
  } catch {
7100
- resolved = null;
7101
- }
7102
- if (resolved) {
7103
- onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
7104
- onSetArtboardPrint?.(artboardId, defaults);
7469
+ /* keep null */
7105
7470
  }
7106
7471
  }
7472
+ // Picking "Digital" clears the explicit prop back to the implicit
7473
+ // default (applySetArtboardKind's `kind: null` path) rather than writing
7474
+ // a redundant `kind="digital"`.
7475
+ onSetArtboardKind?.(artboardId, nextKind === 'digital' ? null : nextKind, seedPrint);
7476
+ // feature-3-web-artboards Design Decision 1 — a web artboard's height
7477
+ // hugs content (`fixed` omitted); switching TO "web" flips hug mode in
7478
+ // the same gesture so the artboard doesn't stay pinned to whatever exact
7479
+ // height a prior digital/print size left it at. Mirrors the print
7480
+ // auto-seed above — switching kind should never require a second,
7481
+ // easy-to-miss step in the Hug/Fixed segmented control above.
7482
+ if (nextKind === 'web' && fixed) {
7483
+ onSetArtboardHug?.(artboardId, false, undefined);
7484
+ }
7107
7485
  };
7108
7486
  const setPrint = (patch) => {
7109
7487
  if (!artboardId) return;
@@ -7180,6 +7558,32 @@ function ArtboardKnobs({
7180
7558
  </option>
7181
7559
  ))}
7182
7560
  </select>
7561
+ ) : kind === 'web' ? (
7562
+ // feature-3-web-artboards T2 — a web artboard's "Preset size…"
7563
+ // dropdown reads as BREAKPOINT selection, not exact-box selection:
7564
+ // same SCREEN_PRESETS widths (Design Decision 2 — no separate
7565
+ // preset table needed), but commits width-only (height stays
7566
+ // hug-driven) and labels each option as the breakpoint it is.
7567
+ <select
7568
+ className="st-cp-nsel"
7569
+ aria-label="artboard breakpoint preset"
7570
+ value={activeWidthPreset ?? ''}
7571
+ onChange={(e) => {
7572
+ const p = SCREEN_PRESETS[e.currentTarget.value];
7573
+ if (p) commitSize(p.width, null);
7574
+ }}
7575
+ >
7576
+ <option value="" disabled>
7577
+ {activeWidthPreset
7578
+ ? `${SCREEN_PRESETS[activeWidthPreset].label} — ≤ ${SCREEN_PRESETS[activeWidthPreset].width}px`
7579
+ : 'Breakpoint…'}
7580
+ </option>
7581
+ {Object.entries(SCREEN_PRESETS).map(([key, p]) => (
7582
+ <option key={key} value={key}>
7583
+ {p.label} — ≤ {p.width}px breakpoint
7584
+ </option>
7585
+ ))}
7586
+ </select>
7183
7587
  ) : (
7184
7588
  <select
7185
7589
  className="st-cp-nsel"
@@ -7223,6 +7627,35 @@ function ArtboardKnobs({
7223
7627
  onChange={setKind}
7224
7628
  />
7225
7629
  </div>
7630
+ {kind !== 'print' && artboardId && onDuplicateArtboard ? (
7631
+ // feature-3-web-artboards T3 — "Duplicate at width…". A stateless
7632
+ // action trigger (not a size preset reflecting current state, unlike
7633
+ // the Preset-size select above), so the control resets to its
7634
+ // placeholder after firing rather than showing the last pick as
7635
+ // though it were now selected. Gated off kind="print" — paper size
7636
+ // is picked via the Artboard preset dropdown above, not a px width.
7637
+ <div style={{ padding: '0 12px 8px' }}>
7638
+ <select
7639
+ className="st-cp-nsel"
7640
+ aria-label="duplicate artboard at breakpoint width"
7641
+ value=""
7642
+ onChange={(e) => {
7643
+ const p = SCREEN_PRESETS[e.currentTarget.value];
7644
+ if (p) onDuplicateArtboard(artboardId, p.width);
7645
+ e.currentTarget.value = '';
7646
+ }}
7647
+ >
7648
+ <option value="" disabled>
7649
+ Duplicate at width…
7650
+ </option>
7651
+ {Object.entries(SCREEN_PRESETS).map(([key, p]) => (
7652
+ <option key={key} value={key}>
7653
+ {p.label} — {p.width}px
7654
+ </option>
7655
+ ))}
7656
+ </select>
7657
+ </div>
7658
+ ) : null}
7226
7659
  {kind === 'print' ? (
7227
7660
  <>
7228
7661
  <div className="st-cp-sechd-row">
@@ -7337,11 +7770,28 @@ function ArtboardKnobs({
7337
7770
  // of the selection's src/html for a selection that reached the panel via a code
7338
7771
  // path predating that field. Returns null for a non-photo element.
7339
7772
  const PHOTO_ASSET_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
7773
+ // Simplifier fix (review fan-out, 2026-07-21) — reuse PHOTO_ASSET_RE's own
7774
+ // source for the global-match variant instead of re-inlining the pattern, so
7775
+ // the two can never drift apart (e.g. an extension-charset fix landing in one
7776
+ // and not the other).
7777
+ const PHOTO_ASSET_RE_G = new RegExp(PHOTO_ASSET_RE.source, 'gi');
7340
7778
  function photoAssetOfSelection(el) {
7341
7779
  if (!el || Array.isArray(el)) return null;
7342
7780
  if (el.photoAsset) return el.photoAsset;
7343
- if ((el.tag || '').toLowerCase() !== 'img') return null;
7344
7781
  const html = el.html || '';
7782
+ // feature-4 regression fix (2026-07-20) — the select tool's bare click now
7783
+ // picks the TOP-LEVEL container, so a photo selection often arrives as the
7784
+ // WRAPPER around the <img>, not the <img> itself. When the selection's
7785
+ // subtree contains EXACTLY ONE content-addressed image, offer the Photo tab
7786
+ // for it (ambiguous multi-photo containers stay photo-less — drill/⌘-click
7787
+ // to pick one).
7788
+ if ((el.tag || '').toLowerCase() !== 'img') {
7789
+ const imgs = [...new Set(html.match(PHOTO_ASSET_RE_G) || [])];
7790
+ const tagged = [...new Set([...html.matchAll(/data-photo-asset="([^"]+)"/g)].map((m) => m[1]))];
7791
+ if (tagged.length === 1) return tagged[0];
7792
+ if (tagged.length === 0 && imgs.length === 1 && /<img/i.test(html)) return imgs[0];
7793
+ return null;
7794
+ }
7345
7795
  // Once an edit is baked, the element's LIVE src is a `data:` URL (canvas-lib's
7346
7796
  // PhotoPreviewBridge swaps it in directly) — it never matches PHOTO_ASSET_RE,
7347
7797
  // so a REPLAYED/serialized selection (a WS resync, or the persisted
@@ -7360,6 +7810,13 @@ function InspectorPanel({
7360
7810
  selected,
7361
7811
  onClose,
7362
7812
  layersTree,
7813
+ // feature-4 T7a — { [cdId]: { component, root, usages } } for purple rows.
7814
+ componentMap,
7815
+ // feature-4 T7b — locked layer keys (`"<id>:<index>"`) + toggle.
7816
+ lockedKeys,
7817
+ onToggleLock,
7818
+ // feature-4 detach-component — clone-definition detach for a shared instance.
7819
+ onDetachInstance,
7363
7820
  canvasFile,
7364
7821
  onSelectLayer,
7365
7822
  onHoverLayer,
@@ -7374,6 +7831,7 @@ function InspectorPanel({
7374
7831
  onSetArtboardStyle,
7375
7832
  onSetArtboardKind,
7376
7833
  onSetArtboardPrint,
7834
+ onDuplicateArtboard,
7377
7835
  onUndoRedo,
7378
7836
  editScope,
7379
7837
  tab: tabProp,
@@ -7420,6 +7878,48 @@ function InspectorPanel({
7420
7878
  // `node.hidden` (the authoritative source value) so a first render of an
7421
7879
  // already-hidden element shows the correct eye-icon state without a click.
7422
7880
  const [hiddenOverride, setHiddenOverride] = useState(() => new Map());
7881
+ // feature-4 T7 — auto-reveal the selected row: expand any collapsed ancestor
7882
+ // group + scroll it into view, so a canvas selection is never hidden behind a
7883
+ // collapsed wrapper (a Figma layers-panel expectation). Runs whenever the
7884
+ // selection or the (HMR-refreshed) tree changes.
7885
+ useEffect(() => {
7886
+ const sel = Array.isArray(selected) ? selected[0] : selected;
7887
+ const selId = sel?.id;
7888
+ if (!selId || !layersTree?.nodes) return;
7889
+ const selIdx = sel.index;
7890
+ let matchedKey = null;
7891
+ const ancestorKeys = [];
7892
+ (function find(nodes, trail) {
7893
+ for (const n of nodes || []) {
7894
+ const k = `${n.id}:${n.index}`;
7895
+ if (!n.synthetic && n.id === selId && (selIdx == null || n.index === selIdx)) {
7896
+ matchedKey = k;
7897
+ ancestorKeys.push(...trail);
7898
+ return true;
7899
+ }
7900
+ if (n.children && n.children.length && find(n.children, [...trail, k])) return true;
7901
+ }
7902
+ return false;
7903
+ })(layersTree.nodes, []);
7904
+ if (!matchedKey) return;
7905
+ if (ancestorKeys.length) {
7906
+ setCollapsed((prev) => {
7907
+ let changed = false;
7908
+ const next = new Set(prev);
7909
+ for (const k of ancestorKeys) if (next.delete(k)) changed = true;
7910
+ return changed ? next : prev;
7911
+ });
7912
+ }
7913
+ requestAnimationFrame(() => {
7914
+ try {
7915
+ document
7916
+ .querySelector(`.st-layer--row[data-layer-key="${matchedKey}"]`)
7917
+ ?.scrollIntoView({ block: 'nearest' });
7918
+ } catch {
7919
+ /* selector edge case — non-fatal */
7920
+ }
7921
+ });
7922
+ }, [selected, layersTree]);
7423
7923
  const isNodeHidden = (node) => {
7424
7924
  const key = `${node.id}:${node.index}`;
7425
7925
  return hiddenOverride.has(key) ? hiddenOverride.get(key) : !!node.hidden;
@@ -7428,11 +7928,41 @@ function InspectorPanel({
7428
7928
  // same drop target) + an aria-live announcement for keyboard moves.
7429
7929
  const [dragState, setDragState] = useState(null);
7430
7930
  const [reorderMsg, setReorderMsg] = useState('');
7931
+ // feature-4 T7c — Layers-panel rename: writes `data-dc-element` (the label's
7932
+ // top-priority source in `layerLabel`) via the existing /_api/edit-attr lane
7933
+ // + records undo through the same source-edit channel CssKnobs uses. The
7934
+ // HMR reload re-posts the tree with the new label.
7935
+ const renameLayer = (node, value) => {
7936
+ if (!canvasFile || !node?.id) return;
7937
+ fetch('/_api/edit-attr', {
7938
+ method: 'POST',
7939
+ headers: { 'content-type': 'application/json' },
7940
+ body: JSON.stringify({ canvas: canvasFile, id: node.id, attr: 'data-dc-element', value }),
7941
+ })
7942
+ .then((r) => r.json().catch(() => ({})))
7943
+ .then((j) => {
7944
+ if (!j.ok) return;
7945
+ onRecordEdit?.({
7946
+ op: 'attr',
7947
+ canvas: canvasFile,
7948
+ id: node.id,
7949
+ key: 'data-dc-element',
7950
+ before: node.dcElement ?? null,
7951
+ after: value,
7952
+ });
7953
+ })
7954
+ .catch(() => {});
7955
+ };
7431
7956
  const handleReorder = onReorderLayer
7432
7957
  ? (dragged, ref, position) => {
7433
7958
  // Gate keyboard + drop moves while a prior reorder is still landing — the
7434
7959
  // write churns positional ids, so acting on the stale tree would misfire.
7435
7960
  if (layersBusyRef?.current) return;
7961
+ // feature-4 T7 — a synthetic group row (unstamped wrapper) has no
7962
+ // data-cd-id to address in source, so it can be neither dragged nor a
7963
+ // drop target. Abort silently rather than post an unresolvable refId
7964
+ // that would optimistically move then revert (a visible flicker).
7965
+ if (dragged?.synthetic || ref?.synthetic) return;
7436
7966
  const verb =
7437
7967
  position === 'before'
7438
7968
  ? `before ${ref.label}`
@@ -7794,6 +8324,19 @@ function InspectorPanel({
7794
8324
  editScope.affects
7795
8325
  } place${editScope.affects === 1 ? '' : 's'}`
7796
8326
  : 'Local · this element only'}
8327
+ {/* feature-4 detach-component (2026-07-19) — make THIS instance its
8328
+ own single-usage component so edits (incl. absolute positions)
8329
+ stay local per artboard. */}
8330
+ {editScope.scope === 'shared' && onDetachInstance ? (
8331
+ <button
8332
+ type="button"
8333
+ className="st-scope-detach"
8334
+ title="Clone this component for this instance only — edits stop affecting the other places"
8335
+ onClick={() => onDetachInstance(el.id, el.index)}
8336
+ >
8337
+ Detach
8338
+ </button>
8339
+ ) : null}
7797
8340
  </div>
7798
8341
  ) : null}
7799
8342
  <div className="st-rp-body">
@@ -7809,15 +8352,16 @@ function InspectorPanel({
7809
8352
  onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
7810
8353
  />
7811
8354
  </div>
7812
- ) : !el ? (
8355
+ ) : !el && !(effTab === 'layers' && layersTree?.nodes?.length) ? (
7813
8356
  <div className="st-rp-empty">
7814
8357
  {/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
7815
8358
  kbd would stack as stretched flex items. */}
7816
8359
  <p>
7817
- Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
8360
+ Press <Kbd>V</Kbd>, then click an element to select it — or <Kbd>⌘</Kbd>-click
8361
+ straight from Browse.
7818
8362
  </p>
7819
8363
  </div>
7820
- ) : effTab === 'inspect' ? (
8364
+ ) : el && effTab === 'inspect' ? (
7821
8365
  <>
7822
8366
  <div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
7823
8367
  <div className="st-insp-row">
@@ -7890,8 +8434,8 @@ function InspectorPanel({
7890
8434
  key={`${n.id}:${n.index}`}
7891
8435
  node={n}
7892
8436
  depth={0}
7893
- selectedId={el.id}
7894
- selectedIndex={el.index}
8437
+ selectedId={el?.id}
8438
+ selectedIndex={el?.index}
7895
8439
  collapsed={collapsed}
7896
8440
  hiddenOverride={hiddenOverride}
7897
8441
  onToggle={toggleCollapse}
@@ -7904,6 +8448,10 @@ function InspectorPanel({
7904
8448
  onReorder={handleReorder}
7905
8449
  onRowPointerDown={startLayerDrag}
7906
8450
  dragState={dragState}
8451
+ componentMap={componentMap}
8452
+ lockedKeys={lockedKeys}
8453
+ onToggleLock={onToggleLock}
8454
+ onRename={renameLayer}
7907
8455
  />
7908
8456
  ))}
7909
8457
  </div>
@@ -7922,7 +8470,7 @@ function InspectorPanel({
7922
8470
  {reorderMsg}
7923
8471
  </div>
7924
8472
  </>
7925
- ) : Array.isArray(el.dom_path) && el.dom_path.length ? (
8473
+ ) : el && Array.isArray(el.dom_path) && el.dom_path.length ? (
7926
8474
  el.dom_path.map((node, i) => (
7927
8475
  <div
7928
8476
  key={i}
@@ -7948,6 +8496,7 @@ function InspectorPanel({
7948
8496
  onSetArtboardStyle={onSetArtboardStyle}
7949
8497
  onSetArtboardKind={onSetArtboardKind}
7950
8498
  onSetArtboardPrint={onSetArtboardPrint}
8499
+ onDuplicateArtboard={onDuplicateArtboard}
7951
8500
  />
7952
8501
  ) : (
7953
8502
  <CssKnobs
@@ -8016,6 +8565,11 @@ function App() {
8016
8565
  // user meanwhile selected something else. select-by-id is idempotent
8017
8566
  // (replace-same → select-set echo → ws guard sees same id → no re-schedule),
8018
8567
  // so the ladder can't loop.
8568
+ // feature-4 dogfood fix — timestamp of the last LOCAL selection change sent
8569
+ // over WS. The ws 'selected' broadcast is both (a) our own echo and (b) a
8570
+ // genuine cross-canvas restore; within this window it's always (a) and must
8571
+ // not overwrite fresher local state (multi-select / drill races).
8572
+ const lastLocalSelectAtRef = useRef(0);
8019
8573
  const haloRestoreTimersRef = useRef([]);
8020
8574
  const scheduleHaloRestore = useCallback((one) => {
8021
8575
  if (!one?.id || !one.file) return;
@@ -8093,6 +8647,27 @@ function App() {
8093
8647
  const layersBusyTimerRef = useRef(null);
8094
8648
  // Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
8095
8649
  const [layersTree, setLayersTree] = useState(null);
8650
+ // feature-4 T7a — Layers-panel component map ({ [cdId]: { component, root,
8651
+ // usages } }) for purple instance rows. Fetched from the read-only main-origin
8652
+ // /_api/component-map on canvas/tree changes, throttled: the map only changes
8653
+ // when SOURCE changes, but the tree re-posts on any DOM churn (drag reflows) —
8654
+ // don't re-parse the canvas for those.
8655
+ const [componentMap, setComponentMap] = useState(null);
8656
+ const componentMapAtRef = useRef({ canvas: null, at: 0 });
8657
+ useEffect(() => {
8658
+ if (!activePath || !layersTree) return;
8659
+ const now = Date.now();
8660
+ const last = componentMapAtRef.current;
8661
+ if (last.canvas === activePath && now - last.at < 1500) return;
8662
+ componentMapAtRef.current = { canvas: activePath, at: now };
8663
+ fetch(`/_api/component-map?canvas=${encodeURIComponent(activePath)}`)
8664
+ .then((r) => r.json())
8665
+ .then((j) => {
8666
+ if (j?.ok && j.map && typeof j.map === 'object') setComponentMap(j.map);
8667
+ else setComponentMap(null);
8668
+ })
8669
+ .catch(() => setComponentMap(null));
8670
+ }, [activePath, layersTree]);
8096
8671
  const [wsConnected, setWsConnected] = useState(false);
8097
8672
  // Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
8098
8673
  // every dirty Y.Doc to disk by the time this state populates, so accepting
@@ -8360,13 +8935,32 @@ function App() {
8360
8935
  const handleAssistantFinished = useCallback(() => {
8361
8936
  if (!assistantOpenRef.current || document.hidden) {
8362
8937
  setAssistantUnseen(true);
8363
- try {
8364
- if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
8365
- new Notification('Claude finished', { body: 'Your assistant turn is ready in Maude.' });
8366
- }
8367
- } catch {
8368
- /* best-effort — the in-app badge is the reliable signal */
8369
- }
8938
+ notifyDesktop('Claude finished', 'Your assistant turn is ready in Maude.');
8939
+ }
8940
+ }, []);
8941
+ // DDR-185 — same "you weren't looking" gate as handleAssistantFinished
8942
+ // above, fired instead when a chat is PAUSED waiting on the user (a
8943
+ // permission prompt or an AskUserQuestion/elicitation form), not just when
8944
+ // a turn completes. A stalled turn otherwise gives no signal at all if the
8945
+ // window isn't focused. Distinct copy so it doesn't read as "done".
8946
+ //
8947
+ // Security-review addendum (Finding G): DDR-179/180 already cap concurrent
8948
+ // pending requests at 10 permissions + 5 elicitations, so a single
8949
+ // adversarial turn can legitimately queue up to 15 DISTINCT approval
8950
+ // decisions — each one now fires a real OS Notification, which is exactly
8951
+ // the burst that manufactures the "rapid Enter-mashing" precondition
8952
+ // DDR-179's own addendum already treats as a security-relevant habituation
8953
+ // risk. The in-app badge (setAssistantUnseen) still reflects EVERY new
8954
+ // request instantly — only the OS notification itself is rate-limited, so
8955
+ // a burst reads as one attention-getting ping, not fifteen.
8956
+ const lastAttentionNotifyRef = useRef(0);
8957
+ const handleAssistantAttention = useCallback(() => {
8958
+ if (!assistantOpenRef.current || document.hidden) {
8959
+ setAssistantUnseen(true);
8960
+ const now = Date.now();
8961
+ if (now - lastAttentionNotifyRef.current < ATTENTION_NOTIFY_COOLDOWN_MS) return;
8962
+ lastAttentionNotifyRef.current = now;
8963
+ notifyDesktop('Maude needs your input', 'Claude is waiting on an approval or a question in Maude.');
8370
8964
  }
8371
8965
  }, []);
8372
8966
  // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
@@ -9071,11 +9665,15 @@ function App() {
9071
9665
  // feature-2-print-artboards T3 — reflect THIS canvas's own persisted
9072
9666
  // print-guides flag in the menu checkbox when switching tabs.
9073
9667
  setPrintGuidesVisible(meta?.overlays?.print === true);
9668
+ // feature-4 T7b — seed this canvas's persisted locked-layer keys
9669
+ // (view.json `locked`, per-user) into the Layers panel state.
9670
+ setLockedKeys(new Set(Array.isArray(meta?.locked) ? meta.locked : []));
9074
9671
  })
9075
9672
  .catch(() => {
9076
9673
  if (!cancelled) {
9077
9674
  setActiveArtboards(0);
9078
9675
  setPrintGuidesVisible(false);
9676
+ setLockedKeys(new Set());
9079
9677
  }
9080
9678
  });
9081
9679
  return () => {
@@ -9083,6 +9681,51 @@ function App() {
9083
9681
  };
9084
9682
  }, [activePath]);
9085
9683
 
9684
+ // feature-4 dogfood fix (2026-07-19) — the Layers panel used to stay EMPTY
9685
+ // until something was selected (the tree only posted on selection). Request
9686
+ // the tree for the viewport-active artboard whenever it (or the canvas)
9687
+ // changes, so Layers work in browse mode with no selection at all. Small
9688
+ // debounce — the active-artboard signal can flicker during a fast pan.
9689
+ useEffect(() => {
9690
+ if (!activePath || activePath === SYSTEM_TAB || !canvasActiveArtboard) return;
9691
+ const t = setTimeout(() => {
9692
+ postToActiveCanvas({ dgn: 'request-layers', artboardId: canvasActiveArtboard });
9693
+ }, 250);
9694
+ return () => clearTimeout(t);
9695
+ }, [activePath, canvasActiveArtboard, postToActiveCanvas]);
9696
+
9697
+ // feature-4 T7b — locked layer keys (`"<cdId>:<index>"`, per-user, per-canvas
9698
+ // via view.json). Toggling PATCHes the FULL set (replace semantics) + pushes
9699
+ // the live set down to the canvas iframe so select/drag enforcement matches
9700
+ // the padlock instantly.
9701
+ const [lockedKeys, setLockedKeys] = useState(() => new Set());
9702
+ const toggleLockedKey = useCallback(
9703
+ (key) => {
9704
+ if (!activePath || activePath === SYSTEM_TAB) return;
9705
+ setLockedKeys((prev) => {
9706
+ const next = new Set(prev);
9707
+ if (next.has(key)) next.delete(key);
9708
+ else next.add(key);
9709
+ const arr = [...next];
9710
+ fetch('/_api/canvas-meta', {
9711
+ method: 'PATCH',
9712
+ headers: { 'Content-Type': 'application/json' },
9713
+ body: JSON.stringify({ file: activePath, patch: { locked: arr } }),
9714
+ }).catch(() => {});
9715
+ postToActiveCanvas({ dgn: 'locked-set', locked: arr });
9716
+ return next;
9717
+ });
9718
+ },
9719
+ [activePath, postToActiveCanvas]
9720
+ );
9721
+ // Push the seed to a (re)loaded canvas too — the iframe reads
9722
+ // `__canvas_meta__.locked` at boot, but a shell-side toggle after a reload
9723
+ // must still reach it; re-broadcast whenever the set or canvas changes.
9724
+ useEffect(() => {
9725
+ if (!activePath || activePath === SYSTEM_TAB) return;
9726
+ postToActiveCanvas({ dgn: 'locked-set', locked: [...lockedKeys] });
9727
+ }, [lockedKeys, activePath, postToActiveCanvas]);
9728
+
9086
9729
  // Sync theme to <html data-theme> + localStorage on every change.
9087
9730
  useEffect(() => {
9088
9731
  try {
@@ -9318,6 +9961,18 @@ function App() {
9318
9961
  // mount it lands after, so re-apply the halo from THIS edge too.
9319
9962
  // Guarded to restore-transitions (prev missing / different id or
9320
9963
  // file) so the select-set echo of our own re-select can't loop.
9964
+ //
9965
+ // feature-4 dogfood fix (2026-07-19) — the frame is ALSO the echo
9966
+ // of our OWN wsSend('select'): it round-trips the server and lands
9967
+ // hundreds of ms later, by which time the user may have drilled
9968
+ // deeper (dblclick) or multi-selected — the echo then OVERWROTE the
9969
+ // fresh state with the stale head and the halo-restore ladder
9970
+ // re-imposed it on the canvas ("multiselect snaps back to one",
9971
+ // "drill snaps back to the parent"). Suppress the restore path
9972
+ // entirely within a short window of any LOCAL selection send — a
9973
+ // genuine cross-canvas restore never follows a local select that
9974
+ // closely (it follows a canvas switch).
9975
+ if (Date.now() - lastLocalSelectAtRef.current < 2000) return;
9321
9976
  const incoming = m.selected;
9322
9977
  const one = Array.isArray(incoming) ? incoming[0] : incoming;
9323
9978
  const prevSel = selectedRef.current;
@@ -9899,6 +10554,17 @@ function App() {
9899
10554
  // only pick a known, always-visible glyph; it cannot inject an
9900
10555
  // invisible/displaced SVG cursor as a clickjacking aid over the un-CSP'd
9901
10556
  // shell (phase-24 ethical-hacker Finding 2; DDR-067).
10557
+ // feature-4 — the browse tool is a pure pass-through: don't force a
10558
+ // global shell cursor, so the shell chrome keeps its own affordance
10559
+ // cursors (pointer over buttons). Also fire the one-time "press V" hint
10560
+ // — a live canvas booting in browse is exactly when it's teachable.
10561
+ if (m.tool === 'browse') {
10562
+ document.body.style.cursor = '';
10563
+ const el = document.getElementById('dc-app-cursor');
10564
+ if (el) el.textContent = '';
10565
+ browseFirstRunHint();
10566
+ return;
10567
+ }
9902
10568
  const cursor = resolveToolCursor(m.tool);
9903
10569
  if (cursor) {
9904
10570
  document.body.style.cursor = cursor;
@@ -9947,11 +10613,13 @@ function App() {
9947
10613
  }
9948
10614
  if (m.dgn === 'select' && m.selection) {
9949
10615
  setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
10616
+ lastLocalSelectAtRef.current = Date.now();
9950
10617
  wsSend({ type: 'select', selection: m.selection });
9951
10618
  setSelected(m.selection);
9952
10619
  maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
9953
10620
  } else if (m.dgn === 'select-set') {
9954
10621
  setPhotoSel(null);
10622
+ lastLocalSelectAtRef.current = Date.now();
9955
10623
  // Canvas multi-select. Payload shape:
9956
10624
  // null → empty selection
9957
10625
  // Selection → length-1 (back-compat with legacy single-element shape)
@@ -9976,6 +10644,7 @@ function App() {
9976
10644
  }
9977
10645
  } else if (m.dgn === 'clear-select') {
9978
10646
  setPhotoSel(null);
10647
+ lastLocalSelectAtRef.current = Date.now();
9979
10648
  wsSend({ type: 'clear-select' });
9980
10649
  setSelected(null);
9981
10650
  } else if (m.dgn === 'edit-text' && m.id) {
@@ -10294,6 +10963,64 @@ function App() {
10294
10963
  before: typeof m.before === 'string' ? m.before : null,
10295
10964
  });
10296
10965
  }
10966
+ } else if (m.dgn === 'convert-to-absolute-request') {
10967
+ // feature-4 T8 (convert-to-absolute, DDR-188) — the canvas measured the
10968
+ // frozen child boxes; perform the main-origin batch write (ONE undo
10969
+ // seq via structuralWrite). The server re-validates every field; here we
10970
+ // just confused-deputy-guard the source (DDR-054) + a light shape check.
10971
+ // Two shapes: single-container (element context menu) or `containers`
10972
+ // batch (the artboard-level "Convert layout to absolute").
10973
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
10974
+ const isBatch = Array.isArray(m.containers) && m.containers.length > 0;
10975
+ const isSingle =
10976
+ typeof m.containerId === 'string' && Array.isArray(m.children) && m.children.length > 0;
10977
+ if (e.source === activeWin && (isBatch || isSingle)) {
10978
+ const count = isBatch
10979
+ ? m.containers.reduce(
10980
+ (n, c) => n + (Array.isArray(c?.children) ? c.children.length : 0),
10981
+ 0
10982
+ )
10983
+ : m.children.length;
10984
+ structuralWriteRef.current?.(
10985
+ '/_api/convert-to-absolute',
10986
+ isBatch
10987
+ ? {
10988
+ allowShared: m.allowShared === true,
10989
+ containers: m.containers,
10990
+ // feature-4 TRUE FLATTEN — unstyled layout wrappers to remove.
10991
+ ...(Array.isArray(m.dissolve) && m.dissolve.length > 0
10992
+ ? { dissolve: m.dissolve }
10993
+ : {}),
10994
+ }
10995
+ : {
10996
+ containerId: m.containerId,
10997
+ containerSetRelative: m.containerSetRelative === true,
10998
+ // feature-4 T8b — the canvas asked the user's confirm already;
10999
+ // the server still refuses `.map` children regardless.
11000
+ allowShared: m.allowShared === true,
11001
+ children: m.children,
11002
+ },
11003
+ {
11004
+ label: 'convert to absolute',
11005
+ // Dogfood 2026-07-19 — the conversion is zero-visual-delta by
11006
+ // design, which LOOKED like "nic nedělá". Say what happened, and
11007
+ // surface server refusals (.map children etc.) as a toast
11008
+ // instead of a console.warn nobody sees.
11009
+ onOk: () => {
11010
+ postToActiveCanvas({ dgn: 'selection-clear' });
11011
+ postToActiveCanvas({
11012
+ dgn: 'op-toast',
11013
+ message: `Converted ${count} element${count === 1 ? '' : 's'} to absolute — press V and drag them freely (⌘Z to undo).`,
11014
+ });
11015
+ },
11016
+ onFail: (j) =>
11017
+ postToActiveCanvas({
11018
+ dgn: 'op-toast',
11019
+ message: `Convert failed: ${j?.error || 'unknown error'}`,
11020
+ }),
11021
+ }
11022
+ );
11023
+ }
10297
11024
  } else if (m.dgn === 'replace-annotation-media-request') {
10298
11025
  // Stage F3 — "Replace…" on an annotation ImageStroke/MediaRefStroke.
10299
11026
  // Opens the SAME AssetPicker; unlike F2 the pick is posted BACK DOWN to
@@ -10370,7 +11097,17 @@ function App() {
10370
11097
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
10371
11098
  const okKind = m.kind === null || typeof m.kind === 'string';
10372
11099
  if (e.source === activeWin && typeof m.artboardId === 'string' && okKind) {
10373
- setArtboardKindShellRef.current?.(m.artboardId, m.kind);
11100
+ // Dogfood round 6 — DIRECT write, never back into the ask flow (the
11101
+ // ask lives shell-side in setArtboardKindShell; re-entering it from
11102
+ // this canvas round-trip looped the confirm dialog forever).
11103
+ directArtboardKindWriteRef.current?.(m.artboardId, m.kind);
11104
+ }
11105
+ } else if (m.dgn === 'duplicate-artboard-request') {
11106
+ // feature-3-web-artboards T3 — context-menu "Duplicate at width…"
11107
+ // submenu (inside the untrusted iframe).
11108
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
11109
+ if (e.source === activeWin && typeof m.artboardId === 'string' && Number.isFinite(m.width)) {
11110
+ duplicateArtboardShellRef.current?.(m.artboardId, m.width);
10374
11111
  }
10375
11112
  } else if (m.dgn === 'open-inspector') {
10376
11113
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
@@ -10929,8 +11666,10 @@ function App() {
10929
11666
  // usage (local), not the shared inner definition. undefined for a plain element.
10930
11667
  const occ = Number.isInteger(idIndex) ? idIndex : undefined;
10931
11668
  // `transform` rides the same lane for the rotate handle (Task L8);
10932
- // `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J) — same
10933
- // single-prop /_api/edit-css write + per-prop undo record, no new lane.
11669
+ // `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J);
11670
+ // `grid-template-columns`/`grid-template-rows` ride it for the on-canvas
11671
+ // grid gutter drag (feature-3-web-artboards T5) — same single-prop
11672
+ // /_api/edit-css write + per-prop undo record, no new lane.
10934
11673
  const props = [
10935
11674
  'width',
10936
11675
  'height',
@@ -10942,6 +11681,8 @@ function App() {
10942
11681
  'padding-bottom',
10943
11682
  'padding-left',
10944
11683
  'gap',
11684
+ 'grid-template-columns',
11685
+ 'grid-template-rows',
10945
11686
  ].filter((p) => typeof patch[p] === 'string' && patch[p]);
10946
11687
  if (!props.length) return;
10947
11688
  // Dogfood 2026-07-07 — "resize paddingu nebo gap" was still deselecting on
@@ -11120,6 +11861,34 @@ function App() {
11120
11861
  [activePath, canvasActiveArtboard, insertElementShell]
11121
11862
  );
11122
11863
 
11864
+ // feature-4 detach-component (2026-07-19) — clone the definition + repoint
11865
+ // this usage so edits stay local to this instance. Rides structuralWrite
11866
+ // (one undo seq); the id churn after HMR invalidates the selection → clear.
11867
+ const detachInstanceShell = useCallback(
11868
+ (id, idIndex) => {
11869
+ structuralWrite(
11870
+ '/_api/detach-component',
11871
+ { id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
11872
+ {
11873
+ label: 'detach instance',
11874
+ onOk: (j) => {
11875
+ postToActiveCanvas({ dgn: 'selection-clear' });
11876
+ postToActiveCanvas({
11877
+ dgn: 'op-toast',
11878
+ message: `Detached — this instance is now ${j.detachedName || 'its own component'}; edits stay local (⌘Z to undo).`,
11879
+ });
11880
+ },
11881
+ onFail: (j) =>
11882
+ postToActiveCanvas({
11883
+ dgn: 'op-toast',
11884
+ message: `Detach failed: ${j?.error || 'unknown error'}`,
11885
+ }),
11886
+ }
11887
+ );
11888
+ },
11889
+ [structuralWrite, postToActiveCanvas]
11890
+ );
11891
+
11123
11892
  const duplicateElementShell = useCallback(
11124
11893
  (id, idIndex) => {
11125
11894
  structuralWrite(
@@ -11248,6 +12017,20 @@ function App() {
11248
12017
  [structuralWrite]
11249
12018
  );
11250
12019
 
12020
+ // feature-3-web-artboards T3 — "Duplicate at width…". No onOk auto-select
12021
+ // (matches insertArtboardShell's own precedent above — the new frame lands
12022
+ // selectable, not auto-selected).
12023
+ const duplicateArtboardShell = useCallback(
12024
+ (artboardId, width) => {
12025
+ structuralWrite(
12026
+ '/_api/duplicate-artboard',
12027
+ { artboardId, width },
12028
+ { label: 'duplicate artboard at width' }
12029
+ );
12030
+ },
12031
+ [structuralWrite]
12032
+ );
12033
+
11251
12034
  // Artboard "hug height" default — Hug ⇄ Fixed toggle from ArtboardKnobs
11252
12035
  // (CSS panel). Direct shell-side action (no canvas postMessage round trip,
11253
12036
  // unlike resizeArtboardShell's drag-overlay caller), so no *ShellRef needed.
@@ -11287,15 +12070,66 @@ function App() {
11287
12070
  // "Preset size…" still listing screen presets) until the user manually
11288
12071
  // re-clicked the artboard — structuralWrite's onOk never refreshed
11289
12072
  // `selected`, only recorded undo history.
11290
- const setArtboardKindShell = useCallback(
12073
+ // feature-4 dogfood round 6 — the DIRECT kind write (+ the Inspector's
12074
+ // print-seed, sequenced AFTER it on the same structuralWrite chain, so a
12075
+ // freeze-convert enqueued just before lands FIRST and the A4 resize can't
12076
+ // move anything pre-freeze). This is what the canvas's
12077
+ // `set-artboard-kind-request` calls — it must NEVER re-enter the ask flow
12078
+ // below (that round-trip was an infinite confirm loop: every "Switch +
12079
+ // freeze" click spawned the next dialog).
12080
+ const pendingPrintSeedRef = useRef(null);
12081
+ const directArtboardKindWrite = useCallback(
11291
12082
  (artboardId, kind) => {
11292
12083
  structuralWrite('/_api/set-artboard-kind', { artboardId, kind }, {
11293
12084
  label: 'artboard kind',
11294
12085
  onOk: () => scheduleArtboardResync(artboardId, activePath),
11295
12086
  });
12087
+ const seed = pendingPrintSeedRef.current;
12088
+ if (kind === 'print' && seed && seed.artboardId === artboardId) {
12089
+ pendingPrintSeedRef.current = null;
12090
+ // Same chain → serialized after the kind write (and after any freeze
12091
+ // convert the canvas enqueued before it).
12092
+ structuralWrite(
12093
+ '/_api/resize-artboard',
12094
+ { artboardId, width: seed.widthPx, height: seed.heightPx },
12095
+ { label: 'artboard size' }
12096
+ );
12097
+ structuralWrite(
12098
+ '/_api/set-artboard-print',
12099
+ { artboardId, print: seed.defaults },
12100
+ { label: 'artboard print' }
12101
+ );
12102
+ }
11296
12103
  },
11297
12104
  [structuralWrite, scheduleArtboardResync, activePath]
11298
12105
  );
12106
+ const directArtboardKindWriteRef = useRef(null);
12107
+ useEffect(() => {
12108
+ directArtboardKindWriteRef.current = directArtboardKindWrite;
12109
+ }, [directArtboardKindWrite]);
12110
+
12111
+ const setArtboardKindShell = useCallback(
12112
+ (artboardId, kind, seedPrint) => {
12113
+ // feature-4 (user steer 2026-07-20) — Digital & Print are the freely-
12114
+ // composed marketing kinds: switching TO them from the Inspector offers
12115
+ // the freeze-and-flatten convert first. The confirm runs IN THE CANVAS
12116
+ // (canvasConfirm — the shell's window.confirm is a silent no-op in the
12117
+ // Tauri WKWebView); the canvas then re-posts `set-artboard-kind-request`,
12118
+ // which lands on the DIRECT writer above (no re-entry → no dialog loop).
12119
+ if (kind === 'print' || kind == null || kind === 'digital') {
12120
+ if (seedPrint) pendingPrintSeedRef.current = { artboardId, ...seedPrint };
12121
+ postToActiveCanvas({
12122
+ dgn: 'freeze-and-set-kind',
12123
+ artboardId,
12124
+ kind: kind === 'digital' ? null : kind,
12125
+ ask: true,
12126
+ });
12127
+ return;
12128
+ }
12129
+ directArtboardKindWrite(artboardId, kind);
12130
+ },
12131
+ [directArtboardKindWrite, postToActiveCanvas]
12132
+ );
11299
12133
  // feature-2-print-artboards T2 — paper/orientation/bleed/margins. Direct
11300
12134
  // Inspector-only callable (no canvas-origin postMessage path — same shape
11301
12135
  // as setArtboardStyleShell, unlike setArtboardKindShell which also has a
@@ -11355,6 +12189,7 @@ function App() {
11355
12189
  const deleteArtboardShellRef = useRef(null);
11356
12190
  const setArtboardKindShellRef = useRef(null);
11357
12191
  const duplicateElementShellRef = useRef(null);
12192
+ const duplicateArtboardShellRef = useRef(null);
11358
12193
  const copyStyleRef = useRef(null);
11359
12194
  const pasteStyleRef = useRef(null);
11360
12195
  useEffect(() => {
@@ -11365,6 +12200,7 @@ function App() {
11365
12200
  deleteArtboardShellRef.current = deleteArtboardShell;
11366
12201
  setArtboardKindShellRef.current = setArtboardKindShell;
11367
12202
  duplicateElementShellRef.current = duplicateElementShell;
12203
+ duplicateArtboardShellRef.current = duplicateArtboardShell;
11368
12204
  copyStyleRef.current = copyStyle;
11369
12205
  pasteStyleRef.current = pasteStyle;
11370
12206
  }, [
@@ -11375,6 +12211,7 @@ function App() {
11375
12211
  deleteArtboardShell,
11376
12212
  setArtboardKindShell,
11377
12213
  duplicateElementShell,
12214
+ duplicateArtboardShell,
11378
12215
  copyStyle,
11379
12216
  pasteStyle,
11380
12217
  ]);
@@ -12124,6 +12961,7 @@ function App() {
12124
12961
  onSetArtboardStyle={setArtboardStyleShell}
12125
12962
  onSetArtboardKind={setArtboardKindShell}
12126
12963
  onSetArtboardPrint={setArtboardPrintShell}
12964
+ onDuplicateArtboard={duplicateArtboardShell}
12127
12965
  editScope={editScope}
12128
12966
  onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
12129
12967
  photoSel={photoSel}
@@ -12133,6 +12971,10 @@ function App() {
12133
12971
  onPhotoRecordEdit={onPhotoRecordEdit}
12134
12972
  onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
12135
12973
  layersTree={layersTree}
12974
+ componentMap={componentMap}
12975
+ lockedKeys={lockedKeys}
12976
+ onToggleLock={toggleLockedKey}
12977
+ onDetachInstance={detachInstanceShell}
12136
12978
  canvasFile={activePath}
12137
12979
  onSelectLayer={(n) =>
12138
12980
  postToActiveCanvas({
@@ -12331,6 +13173,8 @@ function App() {
12331
13173
  onClose={() => setAssistantOpen(false)}
12332
13174
  onBusyChange={setAssistantBusy}
12333
13175
  onFinished={handleAssistantFinished}
13176
+ onPermissionRequest={handleAssistantAttention}
13177
+ onElicitationRequest={handleAssistantAttention}
12334
13178
  />
12335
13179
  )}
12336
13180
  {leftActive && leftActive !== 'assistant' && renderPanelBody(leftActive)}
@@ -12399,6 +13243,8 @@ function App() {
12399
13243
  onClose={() => setAssistantOpen(false)}
12400
13244
  onBusyChange={setAssistantBusy}
12401
13245
  onFinished={handleAssistantFinished}
13246
+ onPermissionRequest={handleAssistantAttention}
13247
+ onElicitationRequest={handleAssistantAttention}
12402
13248
  />
12403
13249
  )}
12404
13250
  {rightActive && rightActive !== 'assistant' && renderPanelBody(rightActive)}