@1agh/maude 0.45.2 → 0.47.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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  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/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -12,6 +12,14 @@ 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';
19
+ // feature-2-print-artboards T2 — the single source (print/units.ts) for the
20
+ // Inspector's paper-preset picker, same "pull only pure math/data" shape as
21
+ // canvas-cursors.ts above.
22
+ import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
15
23
  import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
16
24
  import { canvasUrl } from './canvas-url.js';
17
25
  import ChatPanel from './panels/ChatPanel.jsx';
@@ -29,7 +37,7 @@ import GenerateDialog from './generate-dialog.jsx';
29
37
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
30
38
  import SettingsPanel from './panels/SettingsPanel.jsx';
31
39
  import StickerPicker from './panels/StickerPicker.jsx';
32
- import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
40
+ import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, Select, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
33
41
  import {
34
42
  ALargeSmall as LuALargeSmall,
35
43
  AlignCenter as LuAlignCenter,
@@ -95,6 +103,11 @@ const MINIMAP_STORE = 'mdcc-minimap-visible';
95
103
  const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
96
104
  const ANNOT_STORE = 'mdcc-annotations-visible';
97
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;
98
111
 
99
112
  // feature-unified-settings-modal — the Settings modal's view prefs are ALSO
100
113
  // persisted to disk (~/.config/maude/prefs.json via /_api/ui-prefs), so they
@@ -169,6 +182,20 @@ const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
169
182
  // the bundle is consumed in a context that hasn't run the build.
170
183
  const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
171
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
+
172
199
  function readInitialTheme() {
173
200
  if (typeof window === 'undefined') return 'dark';
174
201
  try {
@@ -248,6 +275,58 @@ function shellToast(message, ok = false) {
248
275
  }, 3200);
249
276
  }
250
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
+
251
330
  // Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
252
331
  // Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
253
332
  // the file type stays unambiguous.
@@ -427,6 +506,19 @@ const STICONS = {
427
506
  </>
428
507
  ),
429
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
+ ),
430
522
  search: (
431
523
  <>
432
524
  <circle cx="7" cy="7" r="4" />
@@ -960,6 +1052,37 @@ const PNG_SCALES = [
960
1052
  { value: 3, label: '3× (max)' },
961
1053
  ];
962
1054
 
1055
+ // feature-2-print-artboards T6 — the PNG card's resolution picker folds the
1056
+ // legacy 1×/2×/3× multiplier and the new physical-DPI ladder (T4) into ONE
1057
+ // select: `kind:'scale'` entries send `options.scale`, `kind:'dpi'` entries
1058
+ // send `options.dpi` (exporters/png.ts resolveDeviceScale — dpi wins over
1059
+ // scale when both could apply). Default stays 2× (unchanged UX for anyone
1060
+ // not exporting for print).
1061
+ const PNG_RESOLUTIONS = [
1062
+ { id: '1x', label: '1× (native)', kind: 'scale', value: 1 },
1063
+ { id: '2x', label: '2× (retina)', kind: 'scale', value: 2 },
1064
+ { id: '3x', label: '3× (max)', kind: 'scale', value: 3 },
1065
+ { id: 'dpi150', label: '150 dpi', kind: 'dpi', value: 150 },
1066
+ { id: 'dpi300', label: '300 dpi (print)', kind: 'dpi', value: 300 },
1067
+ { id: 'dpi600', label: '600 dpi (high-res print)', kind: 'dpi', value: 600 },
1068
+ ];
1069
+ const PNG_RESOLUTION_DEFAULT = '2x';
1070
+
1071
+ // feature-2-print-artboards T5/T6 (dogfood follow-up) — the PDF page itself
1072
+ // is always vector (text/shapes never rasterize), but any RASTER content ON
1073
+ // the artboard — a dropped photo, a large-format piece authored at a
1074
+ // fraction of its real physical size (e.g. a billboard at 1:10 scale) —
1075
+ // still embeds as a bitmap whose density this controls. Default "Auto (1×)"
1076
+ // is today's unchanged behavior; PDF has no legacy scale concept, so unlike
1077
+ // PNG_RESOLUTIONS this is DPI-only.
1078
+ const PDF_DPI_OPTIONS = [
1079
+ { id: 'auto', label: 'Auto (1×)', value: undefined },
1080
+ { id: 'dpi150', label: '150 dpi', value: 150 },
1081
+ { id: 'dpi300', label: '300 dpi (print)', value: 300 },
1082
+ { id: 'dpi600', label: '600 dpi (high-res print)', value: 600 },
1083
+ ];
1084
+ const PDF_DPI_DEFAULT = 'auto';
1085
+
963
1086
  // feature-bulk-media-insert — best-effort MIME guess from a filename extension.
964
1087
  // Only used to classify a natively-picked file (Rust returns `{name, bytes}`,
965
1088
  // no type) for the destination-toggle's image/video/audio split; the actual
@@ -1288,6 +1411,18 @@ function ExportDialog({
1288
1411
  initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
1289
1412
  );
1290
1413
  const [scale, setScale] = useState(2);
1414
+ // feature-2-print-artboards T4/T6 — PNG resolution (folds scale + dpi, see
1415
+ // PNG_RESOLUTIONS above).
1416
+ const [pngResId, setPngResId] = useState(PNG_RESOLUTION_DEFAULT);
1417
+ // feature-2-print-artboards T5/T6 — PDF print options. Sent unconditionally
1418
+ // on every PDF export; the server no-ops them for a non-print artboard (no
1419
+ // `print` JSX prop → applyPrintBoxesAndMarks never runs), so there's no
1420
+ // need to detect the artboard's kind client-side before showing these.
1421
+ const [pdfIncludeBleed, setPdfIncludeBleed] = useState(true);
1422
+ const [pdfMarksOpen, setPdfMarksOpen] = useState(false);
1423
+ const [pdfMarksCrop, setPdfMarksCrop] = useState(false);
1424
+ const [pdfMarksRegistration, setPdfMarksRegistration] = useState(false);
1425
+ const [pdfDpiId, setPdfDpiId] = useState(PDF_DPI_DEFAULT);
1291
1426
  // DDR-148 addendum — mp4/webm of a registered video-comp render through
1292
1427
  // renderMediaOnWeb, which produces real audio (Remotion owns the
1293
1428
  // TransitionSeries/volume-closure timeline). gif has no audio track at all
@@ -1338,12 +1473,31 @@ function ExportDialog({
1338
1473
  }
1339
1474
  setBusy(true);
1340
1475
  setStatus(null);
1341
- // `scale` drives PNG size AND video resolution (deviceScaleFactor → encoder
1342
- // dims); temporal formats were previously fixed at the tiny native size.
1343
- const options = card.format === 'png' || card.temporal ? { scale } : {};
1476
+ // `scale` drives video resolution (deviceScaleFactor → encoder dims);
1477
+ // temporal formats were previously fixed at the tiny native size. PNG's
1478
+ // resolution now comes from pngResId (T4/T6 — scale OR dpi).
1479
+ const options = card.temporal ? { scale } : {};
1480
+ if (card.format === 'png') {
1481
+ const res = PNG_RESOLUTIONS.find((r) => r.id === pngResId) || PNG_RESOLUTIONS[1];
1482
+ if (res.kind === 'dpi') options.dpi = res.value;
1483
+ else options.scale = res.value;
1484
+ }
1344
1485
  // Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
1345
1486
  // format, so the checkbox never renders for it (see hasAudioToggle below).
1346
1487
  if (card.format === 'mp4' || card.format === 'webm') options.audio = audio;
1488
+ // feature-2-print-artboards T5/T6 — always sent on a PDF export; the
1489
+ // adapter no-ops includeBleed/marks for a non-print artboard (T5).
1490
+ if (card.format === 'pdf') {
1491
+ options.pdfPrint = {
1492
+ includeBleed: pdfIncludeBleed,
1493
+ marks: { crop: pdfMarksCrop, registration: pdfMarksRegistration },
1494
+ };
1495
+ // Dogfood follow-up — raster CONTENT on the artboard (a dropped photo,
1496
+ // large-format art) still needs a real capture density; the page
1497
+ // itself stays vector regardless of this.
1498
+ const pdfDpi = PDF_DPI_OPTIONS.find((d) => d.id === pdfDpiId)?.value;
1499
+ if (pdfDpi !== undefined) options.dpi = pdfDpi;
1500
+ }
1347
1501
  // Scope targeting hints (resolveScope reads these): `artboardId` makes
1348
1502
  // "Active artboard" export the right screen instead of `:first-of-type`;
1349
1503
  // `selection` makes "Current selection" export the selected element. Mirrors
@@ -1428,14 +1582,42 @@ function ExportDialog({
1428
1582
  </select>
1429
1583
  </div>
1430
1584
  )}
1431
- {!card.handoff && (card.format === 'png' || card.temporal) && (
1585
+ {!card.handoff && card.format === 'png' && (
1432
1586
  <div className="st-dialog-row">
1433
1587
  <label className="st-dialog-lbl" htmlFor="st-export-size">
1434
- {card.temporal ? 'Resolution' : 'Size'}
1588
+ Resolution
1435
1589
  </label>
1436
1590
  <select
1437
1591
  id="st-export-size"
1438
1592
  className="st-select"
1593
+ value={pngResId}
1594
+ onChange={(e) => setPngResId(e.target.value)}
1595
+ >
1596
+ {PNG_RESOLUTIONS.map((r) => (
1597
+ <option key={r.id} value={r.id}>
1598
+ {r.label}
1599
+ </option>
1600
+ ))}
1601
+ </select>
1602
+ </div>
1603
+ )}
1604
+ {!card.handoff && card.format === 'png' && (
1605
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1606
+ {(() => {
1607
+ const res = PNG_RESOLUTIONS.find((r) => r.id === pngResId) || PNG_RESOLUTIONS[1];
1608
+ const factor = res.kind === 'dpi' ? res.value / 96 : res.value;
1609
+ return `${res.label} ≈ ${Math.round(1440 * factor)}×${Math.round(900 * factor)} for a 1440×900 artboard.`;
1610
+ })()}
1611
+ </div>
1612
+ )}
1613
+ {!card.handoff && card.temporal && (
1614
+ <div className="st-dialog-row">
1615
+ <label className="st-dialog-lbl" htmlFor="st-export-temporal-size">
1616
+ Resolution
1617
+ </label>
1618
+ <select
1619
+ id="st-export-temporal-size"
1620
+ className="st-select"
1439
1621
  value={scale}
1440
1622
  onChange={(e) => setScale(Number(e.target.value))}
1441
1623
  >
@@ -1447,16 +1629,86 @@ function ExportDialog({
1447
1629
  </select>
1448
1630
  </div>
1449
1631
  )}
1450
- {!card.handoff && card.format === 'png' && (
1451
- <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1452
- Resolution multiplier — {scale}× ≈ {1440 * scale}×{900 * scale} for a 1440×900 artboard.
1453
- </div>
1454
- )}
1455
1632
  {!card.handoff && card.temporal && (
1456
1633
  <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1457
1634
  {scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
1458
1635
  </div>
1459
1636
  )}
1637
+ {!card.handoff && card.format === 'pdf' && (
1638
+ <>
1639
+ <div className="st-dialog-row">
1640
+ <label className="st-dialog-lbl" htmlFor="st-export-pdf-dpi">
1641
+ Image quality
1642
+ </label>
1643
+ <select
1644
+ id="st-export-pdf-dpi"
1645
+ className="st-select"
1646
+ value={pdfDpiId}
1647
+ onChange={(e) => setPdfDpiId(e.target.value)}
1648
+ >
1649
+ {PDF_DPI_OPTIONS.map((d) => (
1650
+ <option key={d.id} value={d.id}>
1651
+ {d.label}
1652
+ </option>
1653
+ ))}
1654
+ </select>
1655
+ </div>
1656
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1657
+ The page stays vector; this only sets the capture density for raster content on it
1658
+ (dropped photos, large-format art).
1659
+ </div>
1660
+ <div className="st-dialog-row">
1661
+ <label
1662
+ className="st-dialog-lbl"
1663
+ htmlFor="st-export-pdf-bleed"
1664
+ style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
1665
+ >
1666
+ <input
1667
+ id="st-export-pdf-bleed"
1668
+ type="checkbox"
1669
+ checked={pdfIncludeBleed}
1670
+ onChange={(e) => setPdfIncludeBleed(e.target.checked)}
1671
+ />
1672
+ Include bleed
1673
+ </label>
1674
+ </div>
1675
+ <div className="st-dialog-row">
1676
+ <button
1677
+ type="button"
1678
+ className="st-dialog-lbl"
1679
+ style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer' }}
1680
+ onClick={() => setPdfMarksOpen((v) => !v)}
1681
+ aria-expanded={pdfMarksOpen}
1682
+ >
1683
+ {pdfMarksOpen ? '▾' : '▸'} Marks
1684
+ </button>
1685
+ </div>
1686
+ {pdfMarksOpen && (
1687
+ <div style={{ padding: '0 12px 4px', display: 'flex', flexDirection: 'column', gap: 6 }}>
1688
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12 }}>
1689
+ <input
1690
+ type="checkbox"
1691
+ checked={pdfMarksCrop}
1692
+ onChange={(e) => setPdfMarksCrop(e.target.checked)}
1693
+ />
1694
+ Crop marks
1695
+ </label>
1696
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12 }}>
1697
+ <input
1698
+ type="checkbox"
1699
+ checked={pdfMarksRegistration}
1700
+ onChange={(e) => setPdfMarksRegistration(e.target.checked)}
1701
+ />
1702
+ Registration marks
1703
+ </label>
1704
+ </div>
1705
+ )}
1706
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1707
+ Bleed and marks apply to print (<code>kind="print"</code>) artboards only.
1708
+ {scope === 'canvas-as-separate' ? ' One PDF page per artboard.' : ''}
1709
+ </div>
1710
+ </>
1711
+ )}
1460
1712
  {!card.handoff && (card.format === 'mp4' || card.format === 'webm') && (
1461
1713
  <div className="st-dialog-row">
1462
1714
  <label
@@ -2867,7 +3119,10 @@ function ToolsDropdown({ onAction, onClose }) {
2867
3119
  onAction={onAction}
2868
3120
  onClose={onClose}
2869
3121
  items={[
2870
- { 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' },
2871
3126
  { id: 'hand', label: 'Hand', shortcut: 'H' },
2872
3127
  { id: 'comment', label: 'Comment', shortcut: 'C' },
2873
3128
  { id: 'pen', label: 'Pen', shortcut: 'B' },
@@ -2933,6 +3188,11 @@ function EditDropdown({ onAction, onClose, hasCanvas }) {
2933
3188
  { id: 'new-artboard:laptop', label: 'New artboard: Laptop' },
2934
3189
  { id: 'new-artboard:tablet', label: 'New artboard: Tablet' },
2935
3190
  { id: 'new-artboard:mobile', label: 'New artboard: Mobile' },
3191
+ // feature-2-print-artboards T2 — "+ Artboard" quick-insert must
3192
+ // set kind="print" + the print prop together (the plan's own
3193
+ // gotcha for this task), not just a plain digital-sized board.
3194
+ { id: 'new-artboard:print-a4', label: 'New artboard: A4 (print)' },
3195
+ { id: 'new-artboard:print-letter', label: 'New artboard: Letter (print)' },
2936
3196
  ]
2937
3197
  : []),
2938
3198
  ]}
@@ -2968,6 +3228,8 @@ function Menubar({
2968
3228
  onToggleZoomCtl,
2969
3229
  presentMode,
2970
3230
  onTogglePresent,
3231
+ printGuidesVisible,
3232
+ onTogglePrintGuides,
2971
3233
  postToActiveCanvas,
2972
3234
  onOpenWhatsNew,
2973
3235
  onOpenReadiness,
@@ -3105,6 +3367,18 @@ function Menubar({
3105
3367
  checked: presentMode,
3106
3368
  disabled: !activePath || isSystem,
3107
3369
  },
3370
+ // feature-2-print-artboards T3 — per-canvas persisted (overlays.print in
3371
+ // view.json), same lane as the foundation's `guides` key; NOT gated on the
3372
+ // active artboard actually being kind="print" (mirrors minimap/zoomctl,
3373
+ // which aren't content-gated either — the overlay itself renders nothing
3374
+ // for a non-print artboard regardless of this flag).
3375
+ {
3376
+ id: 'print-guides',
3377
+ label: 'Show print guides',
3378
+ shortcut: '',
3379
+ checked: printGuidesVisible,
3380
+ disabled: !activePath || isSystem,
3381
+ },
3108
3382
  ];
3109
3383
 
3110
3384
  const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
@@ -3242,6 +3516,7 @@ function Menubar({
3242
3516
  else if (id === 'minimap') onToggleMinimap?.();
3243
3517
  else if (id === 'zoomctl') onToggleZoomCtl?.();
3244
3518
  else if (id === 'present') onTogglePresent?.();
3519
+ else if (id === 'print-guides') onTogglePrintGuides?.();
3245
3520
  }}
3246
3521
  onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
3247
3522
  hasCanvas={!!activePath && !isSystem}
@@ -4139,6 +4414,9 @@ function SyncBanner({ status }) {
4139
4414
  // critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
4140
4415
 
4141
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'];
4142
4420
  const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
4143
4421
  const CSS_FLEX_WRAP = ['nowrap', 'wrap', 'wrap-reverse'];
4144
4422
  const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
@@ -4324,6 +4602,43 @@ function cssSplitUnit(v) {
4324
4602
  // click re-derives it live — the "tab is there, then it's gone" flicker.
4325
4603
  function mergeSelClientFields(incoming, prev) {
4326
4604
  if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
4605
+ // Dogfood follow-up (print artboards) — a whole-ARTBOARD selection has NO
4606
+ // data-cd-id (the frame chrome carries only data-dc-screen), so the id
4607
+ // identity check below never matched it and every server echo returned
4608
+ // `incoming` bare — wiping `attrs` (which carries data-dc-kind/-print for
4609
+ // the ArtboardKnobs panel) seconds after each fresh click. The panel then
4610
+ // silently fell back to kind='digital' + screen presets even though the
4611
+ // artboard was print. Match an id-less pair by the artboard-shaped
4612
+ // SELECTOR + file — NOT by artboardId: the server projection
4613
+ // (inspect.ts `enrich()`) doesn't round-trip `artboardId` at all, so an
4614
+ // artboardId comparison always sees undefined on the echo side and never
4615
+ // matches (the second-round bug on this exact spot). The selector IS the
4616
+ // identity here — `[data-dc-screen="<id>"]` — and enrich() preserves it.
4617
+ if (!incoming.id && !prev.id) {
4618
+ const isArtboardSel = (s) =>
4619
+ typeof s.selector === 'string' && s.selector.startsWith('[data-dc-screen=');
4620
+ if (
4621
+ isArtboardSel(incoming) &&
4622
+ isArtboardSel(prev) &&
4623
+ incoming.selector === prev.selector &&
4624
+ incoming.file === prev.file
4625
+ ) {
4626
+ return {
4627
+ ...incoming,
4628
+ authored: incoming.authored ?? prev.authored,
4629
+ computed: incoming.computed ?? prev.computed,
4630
+ customStyles: incoming.customStyles ?? prev.customStyles,
4631
+ attrs: incoming.attrs ?? prev.attrs,
4632
+ // enrich() drops artboardId/worldW/worldH from the echo entirely —
4633
+ // restore them from the local snapshot so ArtboardKnobs keeps its
4634
+ // exact W/H and the resize/kind writers keep their target id.
4635
+ artboardId: incoming.artboardId ?? prev.artboardId,
4636
+ worldW: incoming.worldW ?? prev.worldW,
4637
+ worldH: incoming.worldH ?? prev.worldH,
4638
+ };
4639
+ }
4640
+ return incoming;
4641
+ }
4327
4642
  if (!incoming.id || incoming.id !== prev.id) return incoming;
4328
4643
  return {
4329
4644
  ...incoming,
@@ -4908,6 +5223,98 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
4908
5223
  );
4909
5224
  }
4910
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
+
4911
5318
  function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
4912
5319
  const editable = !!el.id;
4913
5320
  const computed = el.computed || {};
@@ -5254,6 +5661,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5254
5661
  // Props each section owns — drives the per-section "reset section" affordance.
5255
5662
  const SECTION_PROPS = {
5256
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'],
5257
5668
  // feature-element-editing-robustness Stage B — promoted DDR-104 OUT-list.
5258
5669
  Position: ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
5259
5670
  Typography: [
@@ -6151,6 +6562,54 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6151
6562
  })()
6152
6563
  )}
6153
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
+
6154
6613
  {sec(
6155
6614
  'Position',
6156
6615
  <>
@@ -6174,6 +6633,18 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6174
6633
  </>
6175
6634
  )}
6176
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
+
6177
6648
  {sec(
6178
6649
  'Typography',
6179
6650
  <>
@@ -6486,6 +6957,8 @@ const LAYER_TYPE_ICON = {
6486
6957
  list: 'list',
6487
6958
  nav: 'layers',
6488
6959
  box: 'box',
6960
+ // feature-4 T7 — synthetic group row (unstamped wrapper with stamped kids).
6961
+ group: 'folder',
6489
6962
  };
6490
6963
 
6491
6964
 
@@ -6552,21 +7025,52 @@ function LayerRow({
6552
7025
  onReorder,
6553
7026
  onRowPointerDown,
6554
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,
6555
7034
  }) {
6556
7035
  const key = `${node.id}:${node.index}`;
6557
7036
  const hasKids = node.children && node.children.length > 0;
6558
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('');
6559
7050
  // Match the specific INSTANCE (id + occurrence index), not every element that
6560
7051
  // shares this source id — otherwise a `.map`ed element highlights all its
6561
7052
  // clones at once. Fall back to id-only when the selection carries no index.
6562
7053
  const isSel =
6563
- node.id === selectedId && (selectedIndex == null || node.index === selectedIndex);
7054
+ !isSynthetic &&
7055
+ node.id === selectedId &&
7056
+ (selectedIndex == null || node.index === selectedIndex);
6564
7057
  const isHidden = hiddenOverride?.has(key) ? hiddenOverride.get(key) : !!node.hidden;
6565
7058
  // A shared data-cd-id (reused component instance) IS reorderable now — the
6566
7059
  // server maps the occurrence index to the parent <Component> usage — so these
6567
7060
  // are no longer greyed/blocked. A `.map()`ed single-usage element still can't
6568
- // split; that move is refused server-side and reverts.
6569
- 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
+ };
6570
7074
  // Phase 12.1 — the row being dragged FLOATS with the cursor (same model as the
6571
7075
  // in-canvas drag): a transform follows the pointer, its layout box stays
6572
7076
  // reserved (empty slot at the origin), and pointer-events:none lets the row
@@ -6588,26 +7092,46 @@ function LayerRow({
6588
7092
  <>
6589
7093
  <div
6590
7094
  className={
6591
- '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' : '')
6592
7101
  }
6593
7102
  style={{ paddingLeft: 6 + depth * 14, ...dragStyle }}
6594
7103
  role="treeitem"
6595
- aria-selected={isSel}
7104
+ aria-selected={isSynthetic ? undefined : isSel}
6596
7105
  aria-expanded={hasKids ? !isCollapsed : undefined}
6597
7106
  aria-grabbed={canDrag ? isDragging : undefined}
6598
7107
  tabIndex={0}
6599
- 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
+ }
6600
7113
  data-layer-key={key}
6601
- onClick={() => onSelect(node)}
6602
- onMouseEnter={() => onHover(node)}
7114
+ onClick={() => (isSynthetic ? hasKids && onToggle(key) : onSelect(node))}
7115
+ onMouseEnter={() => !isSynthetic && onHover(node)}
6603
7116
  onMouseLeave={() => onHover(null)}
6604
7117
  onPointerDown={canDrag ? (e) => onRowPointerDown(e, node, key) : undefined}
6605
7118
  onKeyDown={(e) => {
6606
- // Enter/Space select; ↑/↓ (+ modifiers) are handled at the tree
6607
- // 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).
6608
7122
  if (e.key === 'Enter' || e.key === ' ') {
6609
7123
  e.preventDefault();
6610
- 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);
6611
7135
  }
6612
7136
  }}
6613
7137
  >
@@ -6626,10 +7150,67 @@ function LayerRow({
6626
7150
  ) : (
6627
7151
  <span className="st-layer-caret" aria-hidden="true" />
6628
7152
  )}
6629
- <StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
6630
- <span className="st-layer-label">{node.label}</span>
6631
- <span className="st-layer-type">{node.type}</span>
6632
- {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 ? (
6633
7214
  <button
6634
7215
  type="button"
6635
7216
  className="st-layer-eye"
@@ -6662,6 +7243,10 @@ function LayerRow({
6662
7243
  onReorder={onReorder}
6663
7244
  onRowPointerDown={onRowPointerDown}
6664
7245
  dragState={dragState}
7246
+ componentMap={componentMap}
7247
+ lockedKeys={lockedKeys}
7248
+ onToggleLock={onToggleLock}
7249
+ onRename={onRename}
6665
7250
  />
6666
7251
  ))
6667
7252
  : null}
@@ -6760,8 +7345,53 @@ const ARTBOARD_LAYOUT_OPTIONS = [
6760
7345
  ['grid', 'Grid'],
6761
7346
  ];
6762
7347
 
6763
- function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardStyle }) {
7348
+ // feature-1-artboard-kinds-foundation, T8 — kind picker options. Order
7349
+ // mirrors the DCArtboard `kind` union; 'digital' is the implicit default
7350
+ // (writes `kind: null`, clearing the explicit prop, per applySetArtboardKind).
7351
+ const ARTBOARD_KIND_OPTIONS = [
7352
+ ['digital', 'Digital'],
7353
+ ['print', 'Print'],
7354
+ ['web', 'Web'],
7355
+ ['video', 'Video'],
7356
+ ];
7357
+
7358
+ function ArtboardKnobs({
7359
+ el,
7360
+ cfg,
7361
+ onResizeArtboard,
7362
+ onSetArtboardHug,
7363
+ onSetArtboardStyle,
7364
+ onSetArtboardKind,
7365
+ onSetArtboardPrint,
7366
+ onDuplicateArtboard,
7367
+ }) {
6764
7368
  const artboardId = resolveArtboardIdFromSelection(el);
7369
+ // Dogfood (artboard panel ↔ shared inspector controls) — the panel now uses
7370
+ // the SAME control library + design-token plumbing as CssKnobs (NumberField
7371
+ // / Segmented / Select / ColorField+TokenPopover / ValueTokenField), so Bg
7372
+ // binds color variables and Pad/Gap bind space variables exactly like any
7373
+ // CSS-panel row. Token resolution mirrors CssKnobs' own tokenGroups/
7374
+ // flatTokens closures over useAllDsTokens.
7375
+ const _designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
7376
+ const _activeDs = activeDsNameFor(el.file, cfg);
7377
+ const allDs = useAllDsTokens(cfg, _designRel, _activeDs);
7378
+ const tokenGroups = (familyKey) =>
7379
+ allDs
7380
+ .map((d) => ({ ds: d.name, names: d[familyKey] || [], vals: d.vals }))
7381
+ .filter((g) => g.names.length);
7382
+ const flatTokens = (familyKey) =>
7383
+ tokenGroups(familyKey).flatMap((g) => (g.names || []).map((n) => ({ name: n, value: g.vals?.[n] || '' })));
7384
+ // Resolve a `var(--x)` binding to its concrete value across every DS (for
7385
+ // the Bg swatch color); a raw value passes through.
7386
+ const resolveTokenValue = (v) => {
7387
+ const m = /^var\(\s*(--[\w-]+)\s*\)$/.exec(v || '');
7388
+ if (!m) return v || '';
7389
+ for (const d of allDs) {
7390
+ const val = d.vals?.[m[1]];
7391
+ if (val) return val;
7392
+ }
7393
+ return '';
7394
+ };
6765
7395
  // Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
6766
7396
  // undefined for a selection that reached here via a code path predating
6767
7397
  // that field (a canvas iframe that hasn't remounted since); fall back to
@@ -6788,6 +7418,12 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
6788
7418
  const activePreset = Object.entries(SCREEN_PRESETS).find(
6789
7419
  ([, p]) => p.width === w && p.height === h
6790
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];
6791
7427
  // Hug default (artboard "hug height") — current mode + the "more settings"
6792
7428
  // (background/padding/layout/gap) read off the SAME generic `attrs` escape
6793
7429
  // hatch dom-selection.ts already scrapes for every selection (the `data-dc-*`
@@ -6798,6 +7434,70 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
6798
7434
  const padding = el.attrs?.['data-dc-padding'] ?? '';
6799
7435
  const layoutMode = el.attrs?.['data-dc-layout'] ?? '';
6800
7436
  const gap = el.attrs?.['data-dc-gap'] ?? '';
7437
+ // T8 — `data-dc-kind` is DCArtboard's RESOLVED kind (readBackAttrs always
7438
+ // emits it, unlike the optional-override attrs above — see T1's comment on
7439
+ // why), so this reads 'digital' even for an implicit/unmigrated artboard.
7440
+ const kind = el.attrs?.['data-dc-kind'] || 'digital';
7441
+ // feature-2-print-artboards T2 — paper/orientation/bleed. Read-back mirrors
7442
+ // `data-dc-kind` above: a small JSON attr DCArtboard stamps for exactly this
7443
+ // panel (canvas-lib.tsx readBackAttrs), since `print` (unlike bg/padding/…)
7444
+ // is object-valued, not a scalar.
7445
+ let print = null;
7446
+ try {
7447
+ print = el.attrs?.['data-dc-print'] ? JSON.parse(el.attrs['data-dc-print']) : null;
7448
+ } catch {
7449
+ /* malformed attr — treat as absent */
7450
+ }
7451
+ const setKind = (nextKind) => {
7452
+ if (!artboardId) return;
7453
+ // Dogfood fix — switching TO "print" with no print prop yet left the
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;
7461
+ if (nextKind === 'print' && !print) {
7462
+ const defaults = { paper: 'a4' };
7463
+ try {
7464
+ const resolved = resolvePrintArtboard(defaults);
7465
+ if (resolved) {
7466
+ seedPrint = { defaults, widthPx: resolved.widthPx, heightPx: resolved.heightPx };
7467
+ }
7468
+ } catch {
7469
+ /* keep null */
7470
+ }
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
+ }
7485
+ };
7486
+ const setPrint = (patch) => {
7487
+ if (!artboardId) return;
7488
+ const next = { paper: 'a4', ...print, ...patch };
7489
+ let resolved;
7490
+ try {
7491
+ resolved = resolvePrintArtboard(next);
7492
+ } catch {
7493
+ return; // unknown paper id — ignore rather than write garbage
7494
+ }
7495
+ // Design Decision 2 — the picker writes BOTH the resolved px size (via
7496
+ // the existing resize lane, DDR-027) and the `print` intent prop, in the
7497
+ // same gesture, so geometry and intent never drift apart.
7498
+ onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
7499
+ onSetArtboardPrint?.(artboardId, next);
7500
+ };
6801
7501
  const setHug = (nextFixed) => {
6802
7502
  if (!artboardId) return;
6803
7503
  const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
@@ -6813,162 +7513,251 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
6813
7513
  <span className="st-cp-sechd">Artboard</span>
6814
7514
  </div>
6815
7515
  <div style={{ display: 'flex', gap: 8, padding: '4px 12px' }}>
6816
- <div className="st-cp-num">
6817
- <span className="st-cp-numlead" aria-hidden="true">
6818
- W
6819
- </span>
6820
- <input
6821
- className="st-cp-numin"
6822
- type="number"
6823
- min="1"
6824
- aria-label="artboard width"
6825
- key={`w:${w ?? ''}`}
6826
- defaultValue={w ?? ''}
6827
- onKeyDown={(e) => {
6828
- if (e.key === 'Enter') e.currentTarget.blur();
6829
- }}
6830
- onBlur={(e) => commitSize(Number.parseFloat(e.currentTarget.value), null)}
6831
- />
6832
- </div>
6833
- <div className="st-cp-num">
6834
- <span className="st-cp-numlead" aria-hidden="true">
6835
- H
6836
- </span>
6837
- <input
6838
- className="st-cp-numin"
6839
- type="number"
6840
- min="1"
6841
- aria-label="artboard height"
6842
- readOnly={!fixed}
6843
- title={fixed ? undefined : 'Hug — grows to fit content. Switch to Fixed to set an exact height.'}
6844
- key={`h:${h ?? ''}:${fixed}`}
6845
- defaultValue={h ?? ''}
6846
- onKeyDown={(e) => {
6847
- if (e.key === 'Enter') e.currentTarget.blur();
6848
- }}
6849
- onBlur={(e) => {
6850
- if (!fixed) return;
6851
- commitSize(null, Number.parseFloat(e.currentTarget.value));
6852
- }}
6853
- />
6854
- </div>
7516
+ <NumberField
7517
+ value={w ?? 0}
7518
+ min={1}
7519
+ ariaLabel="artboard width"
7520
+ lead="W"
7521
+ steppers={false}
7522
+ onCommit={(n) => commitSize(n, null)}
7523
+ />
7524
+ <NumberField
7525
+ value={h ?? 0}
7526
+ min={1}
7527
+ ariaLabel="artboard height"
7528
+ lead="H"
7529
+ steppers={false}
7530
+ scrub={fixed}
7531
+ onCommit={(n) => {
7532
+ // Hug — height is content-driven; ignore a typed value rather
7533
+ // than silently switching modes.
7534
+ if (fixed) commitSize(null, n);
7535
+ }}
7536
+ />
6855
7537
  </div>
6856
7538
  <div style={{ padding: '0 12px 8px' }}>
6857
- <select
6858
- className="st-cp-nsel"
6859
- aria-label="artboard size preset"
6860
- value={activePreset ?? ''}
6861
- onChange={(e) => {
6862
- const p = SCREEN_PRESETS[e.currentTarget.value];
6863
- if (p) commitSize(p.width, p.height);
6864
- }}
6865
- >
6866
- <option value="" disabled>
6867
- {activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
6868
- </option>
6869
- {Object.entries(SCREEN_PRESETS).map(([key, p]) => (
6870
- <option key={key} value={key}>
6871
- {p.label} — {p.width}×{p.height}
7539
+ {kind === 'print' ? (
7540
+ // Dogfood follow-up — a PRINT artboard's "Preset size…" dropdown
7541
+ // showed only the screen presets (Desktop/Laptop/Tablet/Mobile),
7542
+ // which are meaningless for paper. Show the paper ladder here
7543
+ // instead, writing through the SAME setPrint lane as the Print
7544
+ // section below (resolved px + print prop together).
7545
+ <select
7546
+ className="st-cp-nsel"
7547
+ aria-label="artboard paper preset"
7548
+ value={print?.paper ?? ''}
7549
+ onChange={(e) => setPrint({ paper: e.currentTarget.value })}
7550
+ >
7551
+ <option value="" disabled>
7552
+ Paper size…
6872
7553
  </option>
6873
- ))}
6874
- </select>
7554
+ {PAPER_PRESETS.map((p) => (
7555
+ <option key={p.id} value={p.id}>
7556
+ {p.label} — {p.width}×{p.height}
7557
+ {p.unit}
7558
+ </option>
7559
+ ))}
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>
7587
+ ) : (
7588
+ <select
7589
+ className="st-cp-nsel"
7590
+ aria-label="artboard size preset"
7591
+ value={activePreset ?? ''}
7592
+ onChange={(e) => {
7593
+ const p = SCREEN_PRESETS[e.currentTarget.value];
7594
+ if (p) commitSize(p.width, p.height);
7595
+ }}
7596
+ >
7597
+ <option value="" disabled>
7598
+ {activePreset ? SCREEN_PRESETS[activePreset].label : 'Preset size…'}
7599
+ </option>
7600
+ {Object.entries(SCREEN_PRESETS).map(([key, p]) => (
7601
+ <option key={key} value={key}>
7602
+ {p.label} — {p.width}×{p.height}
7603
+ </option>
7604
+ ))}
7605
+ </select>
7606
+ )}
6875
7607
  </div>
6876
7608
  <div style={{ padding: '0 12px 8px' }}>
6877
- <div className="st-cp-modeseg" role="group" aria-label="artboard height sizing mode">
6878
- <span className="st-cp-modeax" aria-hidden="true">
6879
- H
6880
- </span>
6881
- {[
6882
- [false, 'Hug'],
6883
- [true, 'Fixed'],
6884
- ].map(([val, label]) => (
6885
- <button
6886
- key={label}
6887
- type="button"
6888
- className={`st-cp-modebtn${fixed === val ? ' is-active' : ''}`}
6889
- aria-pressed={fixed === val}
6890
- onClick={() => setHug(val)}
6891
- title={`${label} height`}
6892
- >
6893
- {label}
6894
- </button>
6895
- ))}
6896
- </div>
7609
+ <Segmented
7610
+ value={fixed ? 'fixed' : 'hug'}
7611
+ ariaLabel="artboard height sizing mode"
7612
+ options={[
7613
+ { value: 'hug', label: 'Hug' },
7614
+ { value: 'fixed', label: 'Fixed' },
7615
+ ]}
7616
+ onChange={(v) => setHug(v === 'fixed')}
7617
+ />
6897
7618
  </div>
6898
7619
  <div className="st-cp-sechd-row">
6899
- <span className="st-cp-sechd">Style</span>
7620
+ <span className="st-cp-sechd">Kind</span>
6900
7621
  </div>
6901
- <div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
6902
- <div className="st-cp-num" style={{ width: '100%' }}>
6903
- <span className="st-cp-numlead" aria-hidden="true">
6904
- Bg
6905
- </span>
6906
- <input
6907
- className="st-cp-numin"
6908
- type="text"
6909
- aria-label="artboard background"
6910
- placeholder="var(--bg-1)"
6911
- key={`bg:${bg}`}
6912
- defaultValue={bg}
6913
- onKeyDown={(e) => {
6914
- if (e.key === 'Enter') e.currentTarget.blur();
7622
+ <div style={{ padding: '0 12px 8px' }}>
7623
+ <Select
7624
+ value={kind}
7625
+ ariaLabel="artboard kind"
7626
+ options={ARTBOARD_KIND_OPTIONS.map(([value, label]) => ({ value, label }))}
7627
+ onChange={setKind}
7628
+ />
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 = '';
6915
7646
  }}
6916
- onBlur={(e) => commitStyle({ background: e.currentTarget.value.trim() || null })}
6917
- />
6918
- </div>
6919
- <select
6920
- className="st-cp-nsel"
6921
- aria-label="artboard body layout"
6922
- value={layoutMode}
6923
- onChange={(e) => commitStyle({ layout: e.currentTarget.value || null })}
6924
- >
6925
- {ARTBOARD_LAYOUT_OPTIONS.map(([val, label]) => (
6926
- <option key={val} value={val}>
6927
- {label}
7647
+ >
7648
+ <option value="" disabled>
7649
+ Duplicate at width…
6928
7650
  </option>
6929
- ))}
6930
- </select>
6931
- <div style={{ display: 'flex', gap: 8 }}>
6932
- <div className="st-cp-num">
6933
- <span className="st-cp-numlead" aria-hidden="true">
6934
- Pad
6935
- </span>
6936
- <input
6937
- className="st-cp-numin"
6938
- type="number"
6939
- min="0"
6940
- aria-label="artboard padding"
6941
- key={`pad:${padding}`}
6942
- defaultValue={padding}
6943
- onKeyDown={(e) => {
6944
- if (e.key === 'Enter') e.currentTarget.blur();
6945
- }}
6946
- onBlur={(e) => {
6947
- const v = e.currentTarget.value;
6948
- commitStyle({ padding: v === '' ? null : Number.parseFloat(v) });
6949
- }}
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}
7659
+ {kind === 'print' ? (
7660
+ <>
7661
+ <div className="st-cp-sechd-row">
7662
+ <span className="st-cp-sechd">Print</span>
7663
+ </div>
7664
+ {/* Paper picker lives in the Artboard section's own preset dropdown
7665
+ above (it REPLACES the screen presets for kind="print") — this
7666
+ section carries the print-specific residue: orientation + bleed. */}
7667
+ <div style={{ padding: '0 12px 8px' }}>
7668
+ <Segmented
7669
+ value={print?.orientation ?? 'portrait'}
7670
+ ariaLabel="paper orientation"
7671
+ options={[
7672
+ { value: 'portrait', label: 'Portrait' },
7673
+ { value: 'landscape', label: 'Landscape' },
7674
+ ]}
7675
+ onChange={(v) => setPrint({ orientation: v })}
6950
7676
  />
6951
7677
  </div>
6952
- <div className="st-cp-num">
6953
- <span className="st-cp-numlead" aria-hidden="true">
6954
- Gap
6955
- </span>
6956
- <input
6957
- className="st-cp-numin"
6958
- type="number"
6959
- min="0"
6960
- aria-label="artboard gap"
6961
- key={`gap:${gap}`}
6962
- defaultValue={gap}
6963
- onKeyDown={(e) => {
6964
- if (e.key === 'Enter') e.currentTarget.blur();
6965
- }}
6966
- onBlur={(e) => {
6967
- const v = e.currentTarget.value;
6968
- commitStyle({ gap: v === '' ? null : Number.parseFloat(v) });
6969
- }}
7678
+ <div style={{ padding: '0 12px 8px' }}>
7679
+ <NumberField
7680
+ value={Number.isFinite(print?.bleedMm) ? print.bleedMm : 3}
7681
+ min={0}
7682
+ step={0.5}
7683
+ ariaLabel="bleed, millimeters"
7684
+ // Single-glyph lead — the drag-handle slot is sized for 1–2
7685
+ // chars ("W"/"Pad"); a full word clips. mm suffix + aria carry
7686
+ // the meaning.
7687
+ lead="B"
7688
+ steppers={false}
7689
+ unitSlot={
7690
+ <span className="st-cp-numsuffix" aria-hidden="true">
7691
+ mm
7692
+ </span>
7693
+ }
7694
+ onCommit={(n) => setPrint({ bleedMm: n })}
6970
7695
  />
6971
7696
  </div>
7697
+ </>
7698
+ ) : null}
7699
+ <div className="st-cp-sechd-row">
7700
+ <span className="st-cp-sechd">Style</span>
7701
+ </div>
7702
+ <div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
7703
+ {(() => {
7704
+ // Bg — the CssKnobs `color()` recipe: TokenPopover swatch as the
7705
+ // flush prefix inside ColorField (HSV picker + per-DS variables).
7706
+ const bgResolved = resolveTokenValue(bg);
7707
+ const bgBound = /var\(\s*--/.test(bg);
7708
+ const bgDisplay = bgBound
7709
+ ? bg.replace(/^var\(\s*|\s*\)$/g, '').replace(/^--/, '').replace(/-/g, ' ')
7710
+ : bg;
7711
+ return (
7712
+ <ColorField
7713
+ swatch={
7714
+ <TokenPopover
7715
+ kind="color"
7716
+ swatchClassName="st-cp-cf-sw"
7717
+ groups={tokenGroups('color')}
7718
+ current={bg}
7719
+ activeDs={_activeDs}
7720
+ swatchBg={bgResolved}
7721
+ seedHex={cssColorToHex(bgResolved) || '#000000'}
7722
+ onPick={(v) => commitStyle({ background: v || null })}
7723
+ label="artboard background colour"
7724
+ />
7725
+ }
7726
+ displayValue={bgDisplay}
7727
+ bound={bgBound}
7728
+ ariaLabel="artboard background"
7729
+ onValue={(v) => commitStyle({ background: (v || '').trim() || null })}
7730
+ />
7731
+ );
7732
+ })()}
7733
+ <Select
7734
+ value={layoutMode}
7735
+ ariaLabel="artboard body layout"
7736
+ options={ARTBOARD_LAYOUT_OPTIONS.map(([value, label]) => ({
7737
+ value,
7738
+ label,
7739
+ }))}
7740
+ onChange={(v) => commitStyle({ layout: v || null })}
7741
+ />
7742
+ <div style={{ display: 'flex', gap: 8 }}>
7743
+ <ValueTokenField
7744
+ value={/^var\(/.test(padding) ? padding : Number.parseFloat(padding) || 0}
7745
+ tokens={flatTokens('space')}
7746
+ ariaLabel="artboard padding"
7747
+ lead="Pad"
7748
+ min={0}
7749
+ onChange={(v) =>
7750
+ commitStyle({ padding: typeof v === 'string' ? v : v > 0 ? v : null })
7751
+ }
7752
+ />
7753
+ <ValueTokenField
7754
+ value={/^var\(/.test(gap) ? gap : Number.parseFloat(gap) || 0}
7755
+ tokens={flatTokens('space')}
7756
+ ariaLabel="artboard gap"
7757
+ lead="Gap"
7758
+ min={0}
7759
+ onChange={(v) => commitStyle({ gap: typeof v === 'string' ? v : v > 0 ? v : null })}
7760
+ />
6972
7761
  </div>
6973
7762
  </div>
6974
7763
  </section>
@@ -6981,11 +7770,28 @@ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardSt
6981
7770
  // of the selection's src/html for a selection that reached the panel via a code
6982
7771
  // path predating that field. Returns null for a non-photo element.
6983
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');
6984
7778
  function photoAssetOfSelection(el) {
6985
7779
  if (!el || Array.isArray(el)) return null;
6986
7780
  if (el.photoAsset) return el.photoAsset;
6987
- if ((el.tag || '').toLowerCase() !== 'img') return null;
6988
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
+ }
6989
7795
  // Once an edit is baked, the element's LIVE src is a `data:` URL (canvas-lib's
6990
7796
  // PhotoPreviewBridge swaps it in directly) — it never matches PHOTO_ASSET_RE,
6991
7797
  // so a REPLAYED/serialized selection (a WS resync, or the persisted
@@ -7004,6 +7810,13 @@ function InspectorPanel({
7004
7810
  selected,
7005
7811
  onClose,
7006
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,
7007
7820
  canvasFile,
7008
7821
  onSelectLayer,
7009
7822
  onHoverLayer,
@@ -7016,6 +7829,9 @@ function InspectorPanel({
7016
7829
  onResizeArtboard,
7017
7830
  onSetArtboardHug,
7018
7831
  onSetArtboardStyle,
7832
+ onSetArtboardKind,
7833
+ onSetArtboardPrint,
7834
+ onDuplicateArtboard,
7019
7835
  onUndoRedo,
7020
7836
  editScope,
7021
7837
  tab: tabProp,
@@ -7062,6 +7878,48 @@ function InspectorPanel({
7062
7878
  // `node.hidden` (the authoritative source value) so a first render of an
7063
7879
  // already-hidden element shows the correct eye-icon state without a click.
7064
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]);
7065
7923
  const isNodeHidden = (node) => {
7066
7924
  const key = `${node.id}:${node.index}`;
7067
7925
  return hiddenOverride.has(key) ? hiddenOverride.get(key) : !!node.hidden;
@@ -7070,11 +7928,41 @@ function InspectorPanel({
7070
7928
  // same drop target) + an aria-live announcement for keyboard moves.
7071
7929
  const [dragState, setDragState] = useState(null);
7072
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
+ };
7073
7956
  const handleReorder = onReorderLayer
7074
7957
  ? (dragged, ref, position) => {
7075
7958
  // Gate keyboard + drop moves while a prior reorder is still landing — the
7076
7959
  // write churns positional ids, so acting on the stale tree would misfire.
7077
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;
7078
7966
  const verb =
7079
7967
  position === 'before'
7080
7968
  ? `before ${ref.label}`
@@ -7436,6 +8324,19 @@ function InspectorPanel({
7436
8324
  editScope.affects
7437
8325
  } place${editScope.affects === 1 ? '' : 's'}`
7438
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}
7439
8340
  </div>
7440
8341
  ) : null}
7441
8342
  <div className="st-rp-body">
@@ -7451,15 +8352,16 @@ function InspectorPanel({
7451
8352
  onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
7452
8353
  />
7453
8354
  </div>
7454
- ) : !el ? (
8355
+ ) : !el && !(effTab === 'layers' && layersTree?.nodes?.length) ? (
7455
8356
  <div className="st-rp-empty">
7456
8357
  {/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
7457
8358
  kbd would stack as stretched flex items. */}
7458
8359
  <p>
7459
- 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.
7460
8362
  </p>
7461
8363
  </div>
7462
- ) : effTab === 'inspect' ? (
8364
+ ) : el && effTab === 'inspect' ? (
7463
8365
  <>
7464
8366
  <div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
7465
8367
  <div className="st-insp-row">
@@ -7532,8 +8434,8 @@ function InspectorPanel({
7532
8434
  key={`${n.id}:${n.index}`}
7533
8435
  node={n}
7534
8436
  depth={0}
7535
- selectedId={el.id}
7536
- selectedIndex={el.index}
8437
+ selectedId={el?.id}
8438
+ selectedIndex={el?.index}
7537
8439
  collapsed={collapsed}
7538
8440
  hiddenOverride={hiddenOverride}
7539
8441
  onToggle={toggleCollapse}
@@ -7546,6 +8448,10 @@ function InspectorPanel({
7546
8448
  onReorder={handleReorder}
7547
8449
  onRowPointerDown={startLayerDrag}
7548
8450
  dragState={dragState}
8451
+ componentMap={componentMap}
8452
+ lockedKeys={lockedKeys}
8453
+ onToggleLock={onToggleLock}
8454
+ onRename={renameLayer}
7549
8455
  />
7550
8456
  ))}
7551
8457
  </div>
@@ -7564,7 +8470,7 @@ function InspectorPanel({
7564
8470
  {reorderMsg}
7565
8471
  </div>
7566
8472
  </>
7567
- ) : Array.isArray(el.dom_path) && el.dom_path.length ? (
8473
+ ) : el && Array.isArray(el.dom_path) && el.dom_path.length ? (
7568
8474
  el.dom_path.map((node, i) => (
7569
8475
  <div
7570
8476
  key={i}
@@ -7584,9 +8490,13 @@ function InspectorPanel({
7584
8490
  ) : !el.id && resolveArtboardIdFromSelection(el) ? (
7585
8491
  <ArtboardKnobs
7586
8492
  el={el}
8493
+ cfg={cfg}
7587
8494
  onResizeArtboard={onResizeArtboard}
7588
8495
  onSetArtboardHug={onSetArtboardHug}
7589
8496
  onSetArtboardStyle={onSetArtboardStyle}
8497
+ onSetArtboardKind={onSetArtboardKind}
8498
+ onSetArtboardPrint={onSetArtboardPrint}
8499
+ onDuplicateArtboard={onDuplicateArtboard}
7590
8500
  />
7591
8501
  ) : (
7592
8502
  <CssKnobs
@@ -7655,6 +8565,11 @@ function App() {
7655
8565
  // user meanwhile selected something else. select-by-id is idempotent
7656
8566
  // (replace-same → select-set echo → ws guard sees same id → no re-schedule),
7657
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);
7658
8573
  const haloRestoreTimersRef = useRef([]);
7659
8574
  const scheduleHaloRestore = useCallback((one) => {
7660
8575
  if (!one?.id || !one.file) return;
@@ -7680,6 +8595,32 @@ function App() {
7680
8595
  }, delay)
7681
8596
  );
7682
8597
  }, []);
8598
+ // Dogfood follow-up — the ArtboardKnobs panel (Kind/Print/Style/Hug/W-H)
8599
+ // writes via structuralWrite, which never refreshed the Inspector's OWN
8600
+ // `attrs` snapshot afterward: the artboard itself re-rendered correctly
8601
+ // (new kind, new size) but the Inspector kept showing the PRE-edit values
8602
+ // (e.g. "Digital" right after picking "Print") until the user manually
8603
+ // re-clicked the artboard. Mirrors scheduleHaloRestore's retry ladder
8604
+ // (the HMR reload lands async, so a single immediate post races it) but
8605
+ // for a bare ARTBOARD selection (no data-cd-id to key off of — compares
8606
+ // by artboardId instead of id).
8607
+ const artboardResyncTimersRef = useRef([]);
8608
+ const scheduleArtboardResync = useCallback((artboardId, file) => {
8609
+ if (!artboardId || !file) return;
8610
+ for (const t of artboardResyncTimersRef.current) clearTimeout(t);
8611
+ artboardResyncTimersRef.current = [50, 450, 1200, 2500, 5000].map((delay) =>
8612
+ setTimeout(() => {
8613
+ const cur = selectedRef.current;
8614
+ const c1 = Array.isArray(cur) ? cur[0] : cur;
8615
+ if (!c1 || c1.artboardId !== artboardId || c1.file !== file) return; // superseded
8616
+ const frame = iframesRef.current.get(file);
8617
+ if (!frame?.contentWindow) return;
8618
+ try {
8619
+ frame.contentWindow.postMessage({ dgn: 'select-by-id', artboardId }, '*');
8620
+ } catch {}
8621
+ }, delay)
8622
+ );
8623
+ }, []);
7683
8624
  // Phase 12.1 (DDR-138) — after a reorder writes source, the HMR reload remounts
7684
8625
  // the canvas and the positional data-cd-id of the moved element (and everything
7685
8626
  // after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
@@ -7706,6 +8647,27 @@ function App() {
7706
8647
  const layersBusyTimerRef = useRef(null);
7707
8648
  // Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
7708
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]);
7709
8671
  const [wsConnected, setWsConnected] = useState(false);
7710
8672
  // Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
7711
8673
  // every dirty Y.Doc to disk by the time this state populates, so accepting
@@ -7973,13 +8935,32 @@ function App() {
7973
8935
  const handleAssistantFinished = useCallback(() => {
7974
8936
  if (!assistantOpenRef.current || document.hidden) {
7975
8937
  setAssistantUnseen(true);
7976
- try {
7977
- if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
7978
- new Notification('Claude finished', { body: 'Your assistant turn is ready in Maude.' });
7979
- }
7980
- } catch {
7981
- /* best-effort — the in-app badge is the reliable signal */
7982
- }
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.');
7983
8964
  }
7984
8965
  }, []);
7985
8966
  // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
@@ -8195,6 +9176,12 @@ function App() {
8195
9176
  const [minimapVisible, setMinimapVisible] = useState(() => readBoolStore(MINIMAP_STORE, false));
8196
9177
  const [zoomCtlVisible, setZoomCtlVisible] = useState(() => readBoolStore(ZOOMCTL_STORE, false));
8197
9178
  const [presentMode, setPresentMode] = useState(false);
9179
+ // feature-2-print-artboards T3 — "Show print guides". Per-CANVAS persisted
9180
+ // (view.json `overlays.print`, via /_api/canvas-meta), not a global
9181
+ // localStorage pref like minimap/zoomctl — mirrors the foundation's
9182
+ // `overlays.guides` lane. Re-read whenever the active canvas changes (see
9183
+ // the activeArtboards effect below); sent only to the active iframe.
9184
+ const [printGuidesVisible, setPrintGuidesVisible] = useState(false);
8198
9185
  // P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
8199
9186
  // so the top bar no longer mirrors it.)
8200
9187
  // activeArtboards — real artboard count of the open canvas, read from its
@@ -8625,6 +9612,24 @@ function App() {
8625
9612
  setPresentMode(false);
8626
9613
  broadcastChrome({ present: false });
8627
9614
  }, [broadcastChrome]);
9615
+ // feature-2-print-artboards T3 — per-canvas persisted, so (unlike
9616
+ // broadcastChrome above) this targets ONLY the active iframe and PATCHes
9617
+ // view.json's `overlays.print` through the same GET-merge/PATCH-split lane
9618
+ // the foundation built for `overlays.guides` (api.ts normalizeOverlays is a
9619
+ // flat Record<string, boolean> — no server change needed for a new key).
9620
+ const togglePrintGuides = useCallback(() => {
9621
+ if (!activePath || activePath === SYSTEM_TAB) return;
9622
+ setPrintGuidesVisible((v) => {
9623
+ const next = !v;
9624
+ postToActiveCanvas({ dgn: 'view-chrome', print: next });
9625
+ fetch('/_api/canvas-meta', {
9626
+ method: 'PATCH',
9627
+ headers: { 'Content-Type': 'application/json' },
9628
+ body: JSON.stringify({ file: activePath, patch: { overlays: { print: next } } }),
9629
+ }).catch(() => {});
9630
+ return next;
9631
+ });
9632
+ }, [activePath, postToActiveCanvas]);
8628
9633
 
8629
9634
  // P3 (Plan C) — local git user for the menubar presence avatar. One-shot.
8630
9635
  useEffect(() => {
@@ -8647,6 +9652,7 @@ function App() {
8647
9652
  useEffect(() => {
8648
9653
  if (!activePath || activePath === SYSTEM_TAB) {
8649
9654
  setActiveArtboards(0);
9655
+ setPrintGuidesVisible(false);
8650
9656
  return;
8651
9657
  }
8652
9658
  let cancelled = false;
@@ -8656,15 +9662,70 @@ function App() {
8656
9662
  if (cancelled) return;
8657
9663
  const n = Array.isArray(meta?.artboards) ? meta.artboards.length : 0;
8658
9664
  setActiveArtboards(n);
9665
+ // feature-2-print-artboards T3 — reflect THIS canvas's own persisted
9666
+ // print-guides flag in the menu checkbox when switching tabs.
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 : []));
8659
9671
  })
8660
9672
  .catch(() => {
8661
- if (!cancelled) setActiveArtboards(0);
9673
+ if (!cancelled) {
9674
+ setActiveArtboards(0);
9675
+ setPrintGuidesVisible(false);
9676
+ setLockedKeys(new Set());
9677
+ }
8662
9678
  });
8663
9679
  return () => {
8664
9680
  cancelled = true;
8665
9681
  };
8666
9682
  }, [activePath]);
8667
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
+
8668
9729
  // Sync theme to <html data-theme> + localStorage on every change.
8669
9730
  useEffect(() => {
8670
9731
  try {
@@ -8900,6 +9961,18 @@ function App() {
8900
9961
  // mount it lands after, so re-apply the halo from THIS edge too.
8901
9962
  // Guarded to restore-transitions (prev missing / different id or
8902
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;
8903
9976
  const incoming = m.selected;
8904
9977
  const one = Array.isArray(incoming) ? incoming[0] : incoming;
8905
9978
  const prevSel = selectedRef.current;
@@ -9481,6 +10554,17 @@ function App() {
9481
10554
  // only pick a known, always-visible glyph; it cannot inject an
9482
10555
  // invisible/displaced SVG cursor as a clickjacking aid over the un-CSP'd
9483
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
+ }
9484
10568
  const cursor = resolveToolCursor(m.tool);
9485
10569
  if (cursor) {
9486
10570
  document.body.style.cursor = cursor;
@@ -9529,11 +10613,13 @@ function App() {
9529
10613
  }
9530
10614
  if (m.dgn === 'select' && m.selection) {
9531
10615
  setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
10616
+ lastLocalSelectAtRef.current = Date.now();
9532
10617
  wsSend({ type: 'select', selection: m.selection });
9533
10618
  setSelected(m.selection);
9534
10619
  maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
9535
10620
  } else if (m.dgn === 'select-set') {
9536
10621
  setPhotoSel(null);
10622
+ lastLocalSelectAtRef.current = Date.now();
9537
10623
  // Canvas multi-select. Payload shape:
9538
10624
  // null → empty selection
9539
10625
  // Selection → length-1 (back-compat with legacy single-element shape)
@@ -9558,6 +10644,7 @@ function App() {
9558
10644
  }
9559
10645
  } else if (m.dgn === 'clear-select') {
9560
10646
  setPhotoSel(null);
10647
+ lastLocalSelectAtRef.current = Date.now();
9561
10648
  wsSend({ type: 'clear-select' });
9562
10649
  setSelected(null);
9563
10650
  } else if (m.dgn === 'edit-text' && m.id) {
@@ -9876,6 +10963,64 @@ function App() {
9876
10963
  before: typeof m.before === 'string' ? m.before : null,
9877
10964
  });
9878
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
+ }
9879
11024
  } else if (m.dgn === 'replace-annotation-media-request') {
9880
11025
  // Stage F3 — "Replace…" on an annotation ImageStroke/MediaRefStroke.
9881
11026
  // Opens the SAME AssetPicker; unlike F2 the pick is posted BACK DOWN to
@@ -9945,6 +11090,25 @@ function App() {
9945
11090
  if (e.source === activeWin && typeof m.artboardId === 'string') {
9946
11091
  deleteArtboardShellRef.current?.(m.artboardId);
9947
11092
  }
11093
+ } else if (m.dgn === 'set-artboard-kind-request') {
11094
+ // feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
11095
+ // kind" submenu (inside the untrusted iframe). `kind: null` clears
11096
+ // back to the implicit default.
11097
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
11098
+ const okKind = m.kind === null || typeof m.kind === 'string';
11099
+ if (e.source === activeWin && typeof m.artboardId === 'string' && okKind) {
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);
11111
+ }
9948
11112
  } else if (m.dgn === 'open-inspector') {
9949
11113
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
9950
11114
  // feature-photo-editor (Task 16) — an optional `tab` lands the panel on a
@@ -10502,8 +11666,10 @@ function App() {
10502
11666
  // usage (local), not the shared inner definition. undefined for a plain element.
10503
11667
  const occ = Number.isInteger(idIndex) ? idIndex : undefined;
10504
11668
  // `transform` rides the same lane for the rotate handle (Task L8);
10505
- // `padding-*`/`gap` ride it for the on-canvas spacing drag (Stage J) — same
10506
- // 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.
10507
11673
  const props = [
10508
11674
  'width',
10509
11675
  'height',
@@ -10515,6 +11681,8 @@ function App() {
10515
11681
  'padding-bottom',
10516
11682
  'padding-left',
10517
11683
  'gap',
11684
+ 'grid-template-columns',
11685
+ 'grid-template-rows',
10518
11686
  ].filter((p) => typeof patch[p] === 'string' && patch[p]);
10519
11687
  if (!props.length) return;
10520
11688
  // Dogfood 2026-07-07 — "resize paddingu nebo gap" was still deselecting on
@@ -10693,6 +11861,34 @@ function App() {
10693
11861
  [activePath, canvasActiveArtboard, insertElementShell]
10694
11862
  );
10695
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
+
10696
11892
  const duplicateElementShell = useCallback(
10697
11893
  (id, idIndex) => {
10698
11894
  structuralWrite(
@@ -10793,20 +11989,6 @@ function App() {
10793
11989
  [structuralWrite]
10794
11990
  );
10795
11991
 
10796
- // Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
10797
- // id (server 422s a dup, negligible with a random suffix) + a preset label/dims
10798
- // and inserts an empty <DCArtboard> after the last one (runtime default-grid
10799
- // places it; DDR-027). The new frame is then selectable + resizable.
10800
- const onInsertArtboard = useCallback(
10801
- (presetKey) => {
10802
- const p = SCREEN_PRESETS[presetKey];
10803
- if (!p) return;
10804
- const id = `s${Math.random().toString(36).slice(2, 8)}`;
10805
- insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
10806
- },
10807
- [insertArtboardShell]
10808
- );
10809
-
10810
11992
  const resizeArtboardShell = useCallback(
10811
11993
  (artboardId, width, height) => {
10812
11994
  structuralWrite(
@@ -10818,10 +12000,14 @@ function App() {
10818
12000
  // during the drag (no HMR yet); on a rejected/failed write, tell it to
10819
12001
  // restore the pre-drag box so a phantom resize doesn't linger.
10820
12002
  onFail: () => postToActiveCanvas({ dgn: 'resize-artboard-failed' }),
12003
+ // Dogfood follow-up — resync the Inspector's own W/H (and any other
12004
+ // attrs) once the HMR reload lands, so a paper-preset pick doesn't
12005
+ // leave the fields showing the pre-resize size.
12006
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
10821
12007
  }
10822
12008
  );
10823
12009
  },
10824
- [structuralWrite, postToActiveCanvas]
12010
+ [structuralWrite, postToActiveCanvas, scheduleArtboardResync, activePath]
10825
12011
  );
10826
12012
 
10827
12013
  const deleteArtboardShell = useCallback(
@@ -10831,6 +12017,20 @@ function App() {
10831
12017
  [structuralWrite]
10832
12018
  );
10833
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
+
10834
12034
  // Artboard "hug height" default — Hug ⇄ Fixed toggle from ArtboardKnobs
10835
12035
  // (CSS panel). Direct shell-side action (no canvas postMessage round trip,
10836
12036
  // unlike resizeArtboardShell's drag-overlay caller), so no *ShellRef needed.
@@ -10839,18 +12039,147 @@ function App() {
10839
12039
  structuralWrite(
10840
12040
  '/_api/set-artboard-hug',
10841
12041
  { artboardId, fixed, freezeHeight },
10842
- { label: fixed ? 'pin artboard height' : 'hug artboard height' }
12042
+ {
12043
+ label: fixed ? 'pin artboard height' : 'hug artboard height',
12044
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
12045
+ }
10843
12046
  );
10844
12047
  },
10845
- [structuralWrite]
12048
+ [structuralWrite, scheduleArtboardResync, activePath]
10846
12049
  );
10847
12050
 
10848
12051
  // Artboard "more settings" (background/padding/layout/gap) from ArtboardKnobs.
10849
12052
  const setArtboardStyleShell = useCallback(
10850
12053
  (artboardId, patch) => {
10851
- structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, { label: 'artboard style' });
12054
+ structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, {
12055
+ label: 'artboard style',
12056
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
12057
+ });
10852
12058
  },
10853
- [structuralWrite]
12059
+ [structuralWrite, scheduleArtboardResync, activePath]
12060
+ );
12061
+ // feature-1-artboard-kinds-foundation, T8 — kind-switch surfaces. Direct
12062
+ // callable from ArtboardKnobs (Inspector); the context-menu submenu (inside
12063
+ // the iframe) reaches the SAME endpoint via the postMessage ref below.
12064
+ //
12065
+ // Dogfood follow-up — every one of these ArtboardKnobs writers now resyncs
12066
+ // the Inspector's OWN selection snapshot once the HMR reload lands
12067
+ // (scheduleArtboardResync's retry ladder). Before this fix the ARTBOARD
12068
+ // re-rendered correctly (new kind, new paper size) but the Inspector kept
12069
+ // showing PRE-edit attrs (e.g. "Digital" right after picking "Print",
12070
+ // "Preset size…" still listing screen presets) until the user manually
12071
+ // re-clicked the artboard — structuralWrite's onOk never refreshed
12072
+ // `selected`, only recorded undo history.
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(
12082
+ (artboardId, kind) => {
12083
+ structuralWrite('/_api/set-artboard-kind', { artboardId, kind }, {
12084
+ label: 'artboard kind',
12085
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
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
+ }
12103
+ },
12104
+ [structuralWrite, scheduleArtboardResync, activePath]
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
+ );
12133
+ // feature-2-print-artboards T2 — paper/orientation/bleed/margins. Direct
12134
+ // Inspector-only callable (no canvas-origin postMessage path — same shape
12135
+ // as setArtboardStyleShell, unlike setArtboardKindShell which also has a
12136
+ // context-menu caller inside the iframe).
12137
+ const setArtboardPrintShell = useCallback(
12138
+ (artboardId, print) => {
12139
+ structuralWrite('/_api/set-artboard-print', { artboardId, print }, {
12140
+ label: 'artboard print',
12141
+ onOk: () => scheduleArtboardResync(artboardId, activePath),
12142
+ });
12143
+ },
12144
+ [structuralWrite, scheduleArtboardResync, activePath]
12145
+ );
12146
+
12147
+ // Stage I4 — "New artboard: <preset>" from the Edit menu. Generates a unique
12148
+ // id (server 422s a dup, negligible with a random suffix) + a preset label/dims
12149
+ // and inserts an empty <DCArtboard> after the last one (runtime default-grid
12150
+ // places it; DDR-027). The new frame is then selectable + resizable.
12151
+ const onInsertArtboard = useCallback(
12152
+ (presetKey) => {
12153
+ const id = `s${Math.random().toString(36).slice(2, 8)}`;
12154
+ // feature-2-print-artboards T2 — kind="print" + the print prop must
12155
+ // land together, so the created artboard is never a plain digital
12156
+ // board sized to paper by coincidence. structuralWrite serializes
12157
+ // calls in submission order (editApplyChainRef), so these three
12158
+ // sequential POSTs land as insert → kind → print, in order.
12159
+ if (presetKey.startsWith('print-')) {
12160
+ const paper = presetKey.slice('print-'.length);
12161
+ let resolved;
12162
+ try {
12163
+ resolved = resolvePrintArtboard({ paper });
12164
+ } catch {
12165
+ return;
12166
+ }
12167
+ const preset = PAPER_PRESETS.find((p) => p.id === paper);
12168
+ insertArtboardShell({
12169
+ id,
12170
+ label: preset ? preset.label : paper.toUpperCase(),
12171
+ width: resolved.widthPx,
12172
+ height: resolved.heightPx,
12173
+ });
12174
+ setArtboardKindShell(id, 'print');
12175
+ setArtboardPrintShell(id, { paper });
12176
+ return;
12177
+ }
12178
+ const p = SCREEN_PRESETS[presetKey];
12179
+ if (!p) return;
12180
+ insertArtboardShell({ id, label: p.label, width: p.width, height: p.height });
12181
+ },
12182
+ [insertArtboardShell, setArtboardKindShell, setArtboardPrintShell]
10854
12183
  );
10855
12184
  // Refs the (stale-closure) onMessage handlers below read.
10856
12185
  const deleteElementShellRef = useRef(null);
@@ -10858,7 +12187,9 @@ function App() {
10858
12187
  const insertArtboardShellRef = useRef(null);
10859
12188
  const resizeArtboardShellRef = useRef(null);
10860
12189
  const deleteArtboardShellRef = useRef(null);
12190
+ const setArtboardKindShellRef = useRef(null);
10861
12191
  const duplicateElementShellRef = useRef(null);
12192
+ const duplicateArtboardShellRef = useRef(null);
10862
12193
  const copyStyleRef = useRef(null);
10863
12194
  const pasteStyleRef = useRef(null);
10864
12195
  useEffect(() => {
@@ -10867,7 +12198,9 @@ function App() {
10867
12198
  insertArtboardShellRef.current = insertArtboardShell;
10868
12199
  resizeArtboardShellRef.current = resizeArtboardShell;
10869
12200
  deleteArtboardShellRef.current = deleteArtboardShell;
12201
+ setArtboardKindShellRef.current = setArtboardKindShell;
10870
12202
  duplicateElementShellRef.current = duplicateElementShell;
12203
+ duplicateArtboardShellRef.current = duplicateArtboardShell;
10871
12204
  copyStyleRef.current = copyStyle;
10872
12205
  pasteStyleRef.current = pasteStyle;
10873
12206
  }, [
@@ -10876,7 +12209,9 @@ function App() {
10876
12209
  insertArtboardShell,
10877
12210
  resizeArtboardShell,
10878
12211
  deleteArtboardShell,
12212
+ setArtboardKindShell,
10879
12213
  duplicateElementShell,
12214
+ duplicateArtboardShell,
10880
12215
  copyStyle,
10881
12216
  pasteStyle,
10882
12217
  ]);
@@ -11624,6 +12959,9 @@ function App() {
11624
12959
  onResizeArtboard={resizeArtboardShell}
11625
12960
  onSetArtboardHug={setArtboardHugShell}
11626
12961
  onSetArtboardStyle={setArtboardStyleShell}
12962
+ onSetArtboardKind={setArtboardKindShell}
12963
+ onSetArtboardPrint={setArtboardPrintShell}
12964
+ onDuplicateArtboard={duplicateArtboardShell}
11627
12965
  editScope={editScope}
11628
12966
  onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
11629
12967
  photoSel={photoSel}
@@ -11633,6 +12971,10 @@ function App() {
11633
12971
  onPhotoRecordEdit={onPhotoRecordEdit}
11634
12972
  onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
11635
12973
  layersTree={layersTree}
12974
+ componentMap={componentMap}
12975
+ lockedKeys={lockedKeys}
12976
+ onToggleLock={toggleLockedKey}
12977
+ onDetachInstance={detachInstanceShell}
11636
12978
  canvasFile={activePath}
11637
12979
  onSelectLayer={(n) =>
11638
12980
  postToActiveCanvas({
@@ -11733,6 +13075,8 @@ function App() {
11733
13075
  onToggleZoomCtl={toggleZoomCtl}
11734
13076
  presentMode={presentMode}
11735
13077
  onTogglePresent={togglePresent}
13078
+ printGuidesVisible={printGuidesVisible}
13079
+ onTogglePrintGuides={togglePrintGuides}
11736
13080
  postToActiveCanvas={postToActiveCanvas}
11737
13081
  onOpenReadiness={() => setReadinessOpen(true)}
11738
13082
  onOpenQuickSetup={() => setQuickSetupOpen(true)}
@@ -11829,6 +13173,8 @@ function App() {
11829
13173
  onClose={() => setAssistantOpen(false)}
11830
13174
  onBusyChange={setAssistantBusy}
11831
13175
  onFinished={handleAssistantFinished}
13176
+ onPermissionRequest={handleAssistantAttention}
13177
+ onElicitationRequest={handleAssistantAttention}
11832
13178
  />
11833
13179
  )}
11834
13180
  {leftActive && leftActive !== 'assistant' && renderPanelBody(leftActive)}
@@ -11897,6 +13243,8 @@ function App() {
11897
13243
  onClose={() => setAssistantOpen(false)}
11898
13244
  onBusyChange={setAssistantBusy}
11899
13245
  onFinished={handleAssistantFinished}
13246
+ onPermissionRequest={handleAssistantAttention}
13247
+ onElicitationRequest={handleAssistantAttention}
11900
13248
  />
11901
13249
  )}
11902
13250
  {rightActive && rightActive !== 'assistant' && renderPanelBody(rightActive)}