@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
@@ -98,11 +98,12 @@ import {
98
98
  useAnnotationSelectionOptional,
99
99
  } from './use-annotation-selection.tsx';
100
100
  import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
101
- import { showCanvasToast } from './use-canvas-media-drop.tsx';
101
+ import { canvasConfirm, showCanvasToast } from './use-canvas-media-drop.tsx';
102
102
  import { ChromeVisibilityProvider, useChromeVisibility } from './use-chrome-visibility.tsx';
103
103
  import { useCollab } from './use-collab.tsx';
104
104
  import { useCursorModifiers } from './use-cursor-modifiers.tsx';
105
- import { ElementResizeOverlay } from './use-element-resize.tsx';
105
+ import { ElementResizeOverlay, worldZoomFor } from './use-element-resize.tsx';
106
+ import { GridTrackHandlesOverlay } from './use-grid-track-handles.tsx';
106
107
  import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
107
108
  import {
108
109
  MaybeSelectionSetProvider,
@@ -346,6 +347,15 @@ const HALO_CSS = `
346
347
  [data-cd-id]:not(:empty):not(:has(> *)):hover {
347
348
  cursor: text;
348
349
  }
350
+ /* feature-4 text-gestures — the Text tool's hover affordance: the editable
351
+ leaf the click-through would edit gets a dashed accent outline (applied by
352
+ annotations-layer's hit-test; a plain :hover can't reach through the
353
+ tool's input-capture overlay). */
354
+ .dc-text-editable-hover {
355
+ outline: 1.5px dashed var(--maude-hud-accent, #4a63e7);
356
+ outline-offset: 2px;
357
+ border-radius: 2px;
358
+ }
349
359
  /* Phase 12.1 (DDR-138) — in-canvas drag-to-reorder (Figma-style). Dragging a
350
360
  SELECTED element floats it with the cursor (via transform, applied inline —
351
361
  its box stays reserved so the origin shows empty space, and it stays in the
@@ -409,6 +419,16 @@ interface LayerNode {
409
419
  * route CssKnobs uses), so this must reflect the SOURCE value, not a
410
420
  * client-only preview flag (see app.jsx `toggleVisibility`). */
411
421
  hidden: boolean;
422
+ /** feature-4 T7 — a SYNTHETIC group row for an unstamped wrapper that holds
423
+ * stamped descendants. It's a structural stand-in so the tree mirrors real
424
+ * nesting instead of hoisting descendants up a level (the "some elements
425
+ * don't show up / tree is flat" bug). Non-selectable + non-draggable (it
426
+ * has no `data-cd-id` to address in source); its `id` is a stable
427
+ * `__grp:<sig>#<n>` synthetic key, never a real cd-id. */
428
+ synthetic?: boolean;
429
+ /** feature-4 T7c — the raw authored `data-dc-element` attr (null when unset).
430
+ * The rename input's before-value; `layerLabel` renders its Title-Case form. */
431
+ dcElement?: string | null;
412
432
  children: LayerNode[];
413
433
  }
414
434
 
@@ -517,8 +537,18 @@ function buildSelectLayerSubmenu(
517
537
  });
518
538
  }
519
539
 
520
- function serializeArtboardTree(root: Element): LayerNode[] {
540
+ // feature-4 T7 — does this subtree contain ANY stamped element? Gates whether an
541
+ // unstamped wrapper is worth a synthetic group row (an empty/decorative wrapper
542
+ // with no addressable descendants stays omitted — no noise).
543
+ function hasStampedDescendant(el: Element): boolean {
544
+ return !!el.querySelector('[data-cd-id]');
545
+ }
546
+
547
+ export function serializeArtboardTree(root: Element): LayerNode[] {
521
548
  const seen = new Map<string, number>();
549
+ // Occurrence counter for synthetic group signatures so their keys are stable
550
+ // + unique across re-serializations (collapse state persists through HMR).
551
+ const groupSeen = new Map<string, number>();
522
552
  function walk(el: Element): LayerNode[] {
523
553
  const out: LayerNode[] = [];
524
554
  for (const child of Array.from(el.children)) {
@@ -535,12 +565,41 @@ function serializeArtboardTree(root: Element): LayerNode[] {
535
565
  type: layerType(child),
536
566
  index: idx,
537
567
  hidden: (child as HTMLElement).style.display === 'none',
568
+ dcElement: child.getAttribute('data-dc-element'),
569
+ children: walk(child),
570
+ });
571
+ } else if (hasStampedDescendant(child)) {
572
+ // ENGINE chrome wrappers (`.dc-artboard-body`, any `dc-*` layer) are
573
+ // not user structure — hoist THROUGH them (pre-feature-4 behavior) so
574
+ // the tree doesn't grow a phantom "dc-artboard-body GROUP" root row
575
+ // (live-dogfood finding, 2026-07-19).
576
+ const classes = Array.from(child.classList);
577
+ if (classes.some((c) => c.startsWith('dc-') || c.startsWith('dgn-'))) {
578
+ out.push(...walk(child));
579
+ continue;
580
+ }
581
+ // feature-4 T7 — unstamped wrapper WITH stamped descendants: emit a
582
+ // synthetic, non-selectable GROUP row so the tree mirrors real nesting
583
+ // instead of hoisting its descendants up a level. (Pre-feature-4 this
584
+ // branch did `out.push(...walk(child))`, flattening the hierarchy — the
585
+ // "layers are flat / an element is at the wrong depth" report.)
586
+ const tag = child.tagName.toLowerCase();
587
+ const cls = realClasses(child).split(/\s+/).filter(Boolean)[0];
588
+ const sig = cls ? `${tag}.${cls}` : tag;
589
+ const n = groupSeen.get(sig) ?? 0;
590
+ groupSeen.set(sig, n + 1);
591
+ out.push({
592
+ id: `__grp:${sig}#${n}`,
593
+ tag,
594
+ label: layerLabel(child),
595
+ type: 'group',
596
+ index: 0,
597
+ hidden: false,
598
+ synthetic: true,
538
599
  children: walk(child),
539
600
  });
540
- } else {
541
- // Unstamped wrapper — descend so its stamped descendants still surface.
542
- out.push(...walk(child));
543
601
  }
602
+ // Unstamped wrapper with NO stamped descendants → omitted (decorative).
544
603
  }
545
604
  return out;
546
605
  }
@@ -570,10 +629,17 @@ function getLastLayersArtboardId(): string | null {
570
629
  // DDR-148 — an artboard hosting a <VideoComp> stamps a `[data-comp-id]`
571
630
  // element inside it (see video-comp.tsx). Gates the artboard-chrome context
572
631
  // menu's "Open Timeline" entry to artboards that actually have a sequence.
632
+ // T2 (feature-1-artboard-kinds-foundation) — merged with the resolved `kind`:
633
+ // an explicit `kind="video"` (DCArtboard's `data-dc-kind` readback) also
634
+ // qualifies, even before any `<VideoComp>` content exists yet; the structural
635
+ // `[data-comp-id]` check stays as the fallback for unmigrated canvases.
573
636
  function artboardHasVideo(artboardId: string | null | undefined): boolean {
574
637
  if (typeof document === 'undefined' || !artboardId) return false;
575
638
  try {
576
- return !!document.querySelector(`[data-dc-screen="${CSS.escape(artboardId)}"] [data-comp-id]`);
639
+ const root = document.querySelector(`[data-dc-screen="${CSS.escape(artboardId)}"]`);
640
+ if (!root) return false;
641
+ if (root.getAttribute('data-dc-kind') === 'video') return true;
642
+ return !!root.querySelector('[data-comp-id]');
577
643
  } catch {
578
644
  return false;
579
645
  }
@@ -1421,6 +1487,391 @@ function buildRegistry(deps: {
1421
1487
  ],
1422
1488
  };
1423
1489
 
1490
+ // feature-1-artboard-kinds-foundation, T8 — kind-switch submenu, same
1491
+ // shape as themeItem above (a parent-only MenuItem whose onSelect never
1492
+ // fires). The write is main-origin-only (DDR-054): post to the parent
1493
+ // shell (dgn:'set-artboard-kind-request'), which calls
1494
+ // api.setArtboardKindOp and records undo — mirrors delete-artboard-request.
1495
+ const postArtboardKindRequest = (artboardId: string, kind: string | null): void => {
1496
+ try {
1497
+ window.parent.postMessage({ dgn: 'set-artboard-kind-request', artboardId, kind }, '*');
1498
+ } catch {
1499
+ /* detached / cross-origin */
1500
+ }
1501
+ };
1502
+ const kindItem: MenuItem = {
1503
+ id: 'kind',
1504
+ label: 'Artboard kind',
1505
+ onSelect: () => {
1506
+ /* parent of a submenu — never invoked directly */
1507
+ },
1508
+ submenu: [
1509
+ {
1510
+ id: 'kind-digital',
1511
+ label: 'Digital (default)',
1512
+ // feature-4 (user steer 2026-07-19) — digital/print artboards are the
1513
+ // marketing-graphics kinds: on switching, offer to freeze the whole
1514
+ // layout to absolute so every element is freely draggable. Convert
1515
+ // FIRST (freeze at the current visual), then switch the kind.
1516
+ onSelect: (target) => {
1517
+ if (!target.artboardId) return;
1518
+ const artboardId = target.artboardId;
1519
+ void (async () => {
1520
+ const freeze = await canvasConfirm(
1521
+ 'Digital artboards work best with freely movable elements.\nAlso freeze the current layout (convert everything to position: absolute)? This is a one-way change — ⌘Z right after still reverts it.',
1522
+ {
1523
+ title: 'Switch to Digital',
1524
+ confirmLabel: 'Switch + freeze',
1525
+ cancelLabel: 'Just switch',
1526
+ }
1527
+ );
1528
+ if (freeze) await postConvertArtboardToAbsolute(artboardId, { skipConfirm: true });
1529
+ postArtboardKindRequest(artboardId, null);
1530
+ })();
1531
+ },
1532
+ },
1533
+ {
1534
+ id: 'kind-print',
1535
+ label: 'Print',
1536
+ onSelect: (target) => {
1537
+ if (!target.artboardId) return;
1538
+ const artboardId = target.artboardId;
1539
+ void (async () => {
1540
+ const freeze = await canvasConfirm(
1541
+ 'Print artboards work best with freely movable, absolutely positioned elements.\nAlso freeze the current layout (convert everything to position: absolute)? This is a one-way change — ⌘Z right after still reverts it.',
1542
+ {
1543
+ title: 'Switch to Print',
1544
+ confirmLabel: 'Switch + freeze',
1545
+ cancelLabel: 'Just switch',
1546
+ }
1547
+ );
1548
+ if (freeze) await postConvertArtboardToAbsolute(artboardId, { skipConfirm: true });
1549
+ postArtboardKindRequest(artboardId, 'print');
1550
+ })();
1551
+ },
1552
+ },
1553
+ {
1554
+ id: 'kind-web',
1555
+ label: 'Web',
1556
+ onSelect: (target) => {
1557
+ if (target.artboardId) postArtboardKindRequest(target.artboardId, 'web');
1558
+ },
1559
+ },
1560
+ {
1561
+ id: 'kind-video',
1562
+ label: 'Video',
1563
+ onSelect: (target) => {
1564
+ if (target.artboardId) postArtboardKindRequest(target.artboardId, 'video');
1565
+ },
1566
+ },
1567
+ ],
1568
+ };
1569
+
1570
+ // feature-3-web-artboards T3 — "Duplicate at width…" submenu, same
1571
+ // parent-only-MenuItem shape as themeItem/kindItem above. Breakpoint
1572
+ // widths are the same 4 values as the studio shell's own SCREEN_PRESETS
1573
+ // (mobile/tablet/laptop/desktop) — hardcoded here rather than imported
1574
+ // since this module bundles into the canvas iframe, a separate bundle
1575
+ // graph from the studio shell's app.jsx (same reasoning as kindItem
1576
+ // duplicating the ArtboardKind union above).
1577
+ const postDuplicateArtboardRequest = (artboardId: string, width: number): void => {
1578
+ try {
1579
+ window.parent.postMessage({ dgn: 'duplicate-artboard-request', artboardId, width }, '*');
1580
+ } catch {
1581
+ /* detached / cross-origin */
1582
+ }
1583
+ };
1584
+ const DUP_BREAKPOINTS: Array<{ id: string; label: string; width: number }> = [
1585
+ { id: 'dup-bp-mobile', label: 'Mobile — 390px', width: 390 },
1586
+ { id: 'dup-bp-tablet', label: 'Tablet — 834px', width: 834 },
1587
+ { id: 'dup-bp-laptop', label: 'Laptop — 1280px', width: 1280 },
1588
+ { id: 'dup-bp-desktop', label: 'Desktop — 1440px', width: 1440 },
1589
+ ];
1590
+ const duplicateAtBreakpointItem: MenuItem = {
1591
+ id: 'duplicate-at-breakpoint',
1592
+ label: 'Duplicate at width…',
1593
+ onSelect: () => {
1594
+ /* parent of a submenu — never invoked directly */
1595
+ },
1596
+ submenu: DUP_BREAKPOINTS.map(
1597
+ ({ id, label, width }): MenuItem => ({
1598
+ id,
1599
+ label,
1600
+ onSelect: (target) => {
1601
+ if (target.artboardId) postDuplicateArtboardRequest(target.artboardId, width);
1602
+ },
1603
+ })
1604
+ ),
1605
+ };
1606
+
1607
+ // feature-4 T8 (convert-to-absolute, DDR-188) — the DIRECT stamped children of
1608
+ // a container that are eligible for the "Remove auto layout" convert: skips
1609
+ // dev-server chrome, requires a data-cd-id. Returns null when the element
1610
+ // isn't a convertible container (no stamped direct children).
1611
+ const convertibleChildrenOf = (el: Element | null): HTMLElement[] | null => {
1612
+ if (!el) return null;
1613
+ const kids = Array.from(el.children).filter(
1614
+ (c) => !c.closest('.dgn-pin, .dc-cv-halo, .dc-cv-group-bbox')
1615
+ ) as HTMLElement[];
1616
+ const stamped = kids.filter((c) => c.hasAttribute('data-cd-id'));
1617
+ return stamped.length > 0 ? stamped : null;
1618
+ };
1619
+ // Count how many elements in the artboard body carry `cdId` — >1 means a
1620
+ // repeated (`.map`) or shared-component instance, which convert-to-absolute
1621
+ // refuses (one source element can't hold N distinct absolute positions).
1622
+ const cdIdCountInArtboard = (el: Element, cdId: string): number => {
1623
+ const body = el.closest('.dc-artboard-body') ?? el.ownerDocument;
1624
+ try {
1625
+ return body.querySelectorAll(`[data-cd-id="${CSS.escape(cdId)}"]`).length;
1626
+ } catch {
1627
+ return 99;
1628
+ }
1629
+ };
1630
+ // feature-4 unified subtree convert (dogfood round 4, 2026-07-20) — ONE
1631
+ // walk + ONE confirm for the whole subtree, whether rooted at a right-
1632
+ // clicked container (element context menu) or the artboard body. The
1633
+ // earlier per-container element action made the user confirm once PER
1634
+ // nested container ("modal vyskočí 10×"); now a single action converts the
1635
+ // entire subtree in one batch (one undo seq), dissolving unstyled layout
1636
+ // wrappers along the way.
1637
+ const isUnstyledWrapper = (el: HTMLElement): boolean => {
1638
+ const cs = getComputedStyle(el);
1639
+ const bg = cs.backgroundColor;
1640
+ const transparentBg = bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)';
1641
+ return (
1642
+ transparentBg &&
1643
+ cs.backgroundImage === 'none' &&
1644
+ (Number.parseFloat(cs.borderTopWidth) || 0) === 0 &&
1645
+ (Number.parseFloat(cs.borderRightWidth) || 0) === 0 &&
1646
+ (Number.parseFloat(cs.borderBottomWidth) || 0) === 0 &&
1647
+ (Number.parseFloat(cs.borderLeftWidth) || 0) === 0 &&
1648
+ cs.boxShadow === 'none' &&
1649
+ (cs.borderRadius === '0px' || cs.borderRadius === '0') &&
1650
+ cs.overflow !== 'hidden' &&
1651
+ cs.overflowX !== 'hidden' &&
1652
+ cs.overflowY !== 'hidden' &&
1653
+ cs.opacity === '1' &&
1654
+ cs.filter === 'none' &&
1655
+ cs.transform === 'none'
1656
+ );
1657
+ };
1658
+
1659
+ type ConvertChildBox = {
1660
+ id: string;
1661
+ idIndex?: number;
1662
+ left: number;
1663
+ top: number;
1664
+ width: number;
1665
+ height: number;
1666
+ };
1667
+ type ConvertEntry = {
1668
+ containerId?: string;
1669
+ containerSetRelative: boolean;
1670
+ freezeSize?: { width: number; height: number };
1671
+ children: ConvertChildBox[];
1672
+ };
1673
+
1674
+ /**
1675
+ * Convert an entire SUBTREE to absolute in one batch: `rootEl` is either the
1676
+ * artboard body (artboard-level, `rootId` undefined) or a right-clicked
1677
+ * container (element-level, `rootId` = its cd-id — the root itself keeps its
1678
+ * flow position; only its descendants freeze). Exactly ONE confirm per
1679
+ * invocation (skippable), an in-flight guard so double-invocations can't
1680
+ * stack dialogs, and the rest happens in the background.
1681
+ */
1682
+ const runSubtreeConvert = async (
1683
+ rootEl: HTMLElement,
1684
+ rootId: string | undefined,
1685
+ artboardId: string,
1686
+ opts?: { skipConfirm?: boolean }
1687
+ ): Promise<void> => {
1688
+ const w = window as unknown as { __maudeConvertBusy?: boolean };
1689
+ if (w.__maudeConvertBusy) return;
1690
+ w.__maudeConvertBusy = true;
1691
+ try {
1692
+ const body = rootEl.closest('.dc-artboard-body') ?? rootEl;
1693
+ const zoom = worldZoomFor(rootEl) || 1;
1694
+ const containers: ConvertEntry[] = [];
1695
+ const dissolve: string[] = [];
1696
+ let unstamped = 0;
1697
+ let repeated = 0;
1698
+ const countIn = (cid: string): number => {
1699
+ try {
1700
+ return body.querySelectorAll(`[data-cd-id="${CSS.escape(cid)}"]`).length;
1701
+ } catch {
1702
+ return 99;
1703
+ }
1704
+ };
1705
+ const measure = (el: HTMLElement, vs: HTMLElement): Omit<ConvertChildBox, 'id'> => {
1706
+ const r = el.getBoundingClientRect();
1707
+ const sRect = vs.getBoundingClientRect();
1708
+ const scs = getComputedStyle(vs);
1709
+ const borderL = Number.parseFloat(scs.borderLeftWidth) || 0;
1710
+ const borderT = Number.parseFloat(scs.borderTopWidth) || 0;
1711
+ return {
1712
+ left: Math.round((r.left - sRect.left) / zoom - borderL),
1713
+ top: Math.round((r.top - sRect.top) / zoom - borderT),
1714
+ width: Math.round(r.width / zoom),
1715
+ height: Math.round(r.height / zoom),
1716
+ };
1717
+ };
1718
+ const walk = (
1719
+ containerEl: HTMLElement,
1720
+ survivingEl: HTMLElement,
1721
+ survivingEntry: ConvertEntry
1722
+ ): void => {
1723
+ for (const c of Array.from(containerEl.children) as HTMLElement[]) {
1724
+ if (c.closest('.dgn-pin, .dc-cv-halo, .dc-cv-group-bbox')) continue;
1725
+ const cid = c.getAttribute('data-cd-id');
1726
+ if (!cid) {
1727
+ unstamped += 1;
1728
+ continue;
1729
+ }
1730
+ const count = countIn(cid);
1731
+ if (count > 1) repeated += 1;
1732
+ const hasStampedKids = !!c.querySelector('[data-cd-id]');
1733
+ const dissolvable = hasStampedKids && count === 1 && isUnstyledWrapper(c);
1734
+ if (dissolvable) {
1735
+ dissolve.push(cid);
1736
+ walk(c, survivingEl, survivingEntry);
1737
+ continue;
1738
+ }
1739
+ const idIndex =
1740
+ count > 1
1741
+ ? Math.max(
1742
+ 0,
1743
+ Array.from(body.querySelectorAll(`[data-cd-id="${CSS.escape(cid)}"]`)).indexOf(c)
1744
+ )
1745
+ : undefined;
1746
+ survivingEntry.children.push({
1747
+ id: cid,
1748
+ ...(idIndex !== undefined ? { idIndex } : {}),
1749
+ ...measure(c, survivingEl),
1750
+ });
1751
+ if (hasStampedKids && count === 1) {
1752
+ const entry: ConvertEntry = {
1753
+ containerId: cid,
1754
+ containerSetRelative: getComputedStyle(c).position === 'static',
1755
+ children: [],
1756
+ };
1757
+ containers.push(entry);
1758
+ walk(c, c, entry);
1759
+ }
1760
+ // A repeated (component-instance) container freezes as a WRAPPED
1761
+ // unit — we don't descend into shared internals (per-instance inner
1762
+ // edits need detach; the whole instance is draggable via its box).
1763
+ }
1764
+ };
1765
+ const rootEntry: ConvertEntry = {
1766
+ ...(rootId !== undefined ? { containerId: rootId } : {}),
1767
+ containerSetRelative:
1768
+ rootId !== undefined && getComputedStyle(rootEl).position === 'static',
1769
+ // The element-level root keeps its FLOW position but its auto height
1770
+ // would collapse once all children go absolute — freeze its box.
1771
+ ...(rootId !== undefined
1772
+ ? {
1773
+ freezeSize: {
1774
+ width: Math.round(rootEl.getBoundingClientRect().width / zoom),
1775
+ height: Math.round(rootEl.getBoundingClientRect().height / zoom),
1776
+ },
1777
+ }
1778
+ : {}),
1779
+ children: [],
1780
+ };
1781
+ containers.push(rootEntry);
1782
+ walk(rootEl, rootEl, rootEntry);
1783
+ const nonEmpty = containers.filter((c) => c.children.length > 0 || c.containerSetRelative);
1784
+ const total = nonEmpty.reduce((n, c) => n + c.children.length, 0);
1785
+ if (total === 0) {
1786
+ showCanvasToast('Nothing to convert — no selectable elements here.');
1787
+ return;
1788
+ }
1789
+ if (unstamped > 0) {
1790
+ showCanvasToast('Some elements have no id — edit the canvas via chat first, then convert.');
1791
+ return;
1792
+ }
1793
+ // ONE confirm for the WHOLE subtree (user steer: "stačí jednou a zbytek
1794
+ // proveď na pozadí"). Skipped entirely for kind-switch (the shell/canvas
1795
+ // already asked there).
1796
+ if (opts?.skipConfirm !== true) {
1797
+ const parts = [
1798
+ `Freeze all ${total} elements at their current positions (position: absolute)?`,
1799
+ dissolve.length > 0
1800
+ ? `${dissolve.length} invisible layout wrapper(s) will be removed — the tree flattens.`
1801
+ : 'Every element becomes freely draggable — the layout will no longer reflow.',
1802
+ 'This is a one-way change (⌘Z right after still reverts it).',
1803
+ ];
1804
+ if (repeated > 0) {
1805
+ parts.push(
1806
+ `${repeated} element(s) render from shared components — each instance keeps its own frozen position.`
1807
+ );
1808
+ }
1809
+ const ok = await canvasConfirm(parts.join('\n'), {
1810
+ title:
1811
+ rootId !== undefined
1812
+ ? 'Convert this container to absolute?'
1813
+ : 'Convert artboard layout to absolute?',
1814
+ confirmLabel: 'Convert all',
1815
+ });
1816
+ if (!ok) return;
1817
+ }
1818
+ try {
1819
+ window.parent.postMessage(
1820
+ {
1821
+ dgn: 'convert-to-absolute-request',
1822
+ artboardId,
1823
+ allowShared: repeated > 0,
1824
+ containers: nonEmpty,
1825
+ ...(dissolve.length > 0 ? { dissolve } : {}),
1826
+ },
1827
+ '*'
1828
+ );
1829
+ } catch {
1830
+ /* detached / cross-origin */
1831
+ }
1832
+ } finally {
1833
+ w.__maudeConvertBusy = false;
1834
+ }
1835
+ };
1836
+
1837
+ // Element context menu — recursive over the clicked container's subtree.
1838
+ const postConvertToAbsolute = async (containerEl: HTMLElement): Promise<void> => {
1839
+ const containerId = containerEl.getAttribute('data-cd-id');
1840
+ if (!containerId) {
1841
+ showCanvasToast('That container has no id yet — edit it via chat first, then convert.');
1842
+ return;
1843
+ }
1844
+ const artboardId =
1845
+ (containerEl.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute(
1846
+ 'data-dc-screen'
1847
+ ) ?? '';
1848
+ if (!artboardId) return;
1849
+ if (cdIdCountInArtboard(containerEl, containerId) > 1) {
1850
+ showCanvasToast(
1851
+ 'This container is a repeated/component instance — convert isn\u2019t supported for it yet.'
1852
+ );
1853
+ return;
1854
+ }
1855
+ await runSubtreeConvert(containerEl, containerId, artboardId);
1856
+ };
1857
+ // Artboard-level (context menu + kind switch).
1858
+ const postConvertArtboardToAbsolute = async (
1859
+ artboardId: string,
1860
+ opts?: { skipConfirm?: boolean }
1861
+ ): Promise<void> => {
1862
+ const body = document.querySelector(
1863
+ `[data-dc-screen="${CSS.escape(artboardId)}"] .dc-artboard-body`
1864
+ ) as HTMLElement | null;
1865
+ if (!body) return;
1866
+ await runSubtreeConvert(body, undefined, artboardId, opts);
1867
+ };
1868
+
1869
+ // Publish for the CanvasRouter's `freeze-and-set-kind` handler (different
1870
+ // scope; window-level so it survives soft HMR, same as __maudeLastLayersArt).
1871
+ (
1872
+ window as unknown as { __maudeConvertArtboard?: typeof postConvertArtboardToAbsolute }
1873
+ ).__maudeConvertArtboard = postConvertArtboardToAbsolute;
1874
+
1424
1875
  return {
1425
1876
  element: [
1426
1877
  [
@@ -1600,6 +2051,18 @@ function buildRegistry(deps: {
1600
2051
  }
1601
2052
  },
1602
2053
  },
2054
+ {
2055
+ // feature-4 T8 (convert-to-absolute, DDR-188) — the Figma "Remove auto
2056
+ // layout" analogue. Only shown on a container with stamped children;
2057
+ // freezes each child's computed box and rewrites them to
2058
+ // position:absolute so they can be dragged freely.
2059
+ id: 'convert-to-absolute',
2060
+ label: 'Convert children to absolute position',
2061
+ hidden: (target) => !convertibleChildrenOf(target.el),
2062
+ onSelect: (target) => {
2063
+ if (target.el) postConvertToAbsolute(target.el as HTMLElement);
2064
+ },
2065
+ },
1603
2066
  ],
1604
2067
  [exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
1605
2068
  [
@@ -1689,6 +2152,18 @@ function buildRegistry(deps: {
1689
2152
  fitItem,
1690
2153
  resetItem,
1691
2154
  ],
2155
+ [
2156
+ {
2157
+ // feature-4 (user steer 2026-07-19) — freeze the WHOLE artboard's
2158
+ // layout: every element position:absolute at its current box, so the
2159
+ // composition is freely draggable (marketing-graphics flow).
2160
+ id: 'convert-artboard-absolute',
2161
+ label: 'Convert layout to absolute…',
2162
+ onSelect: (target) => {
2163
+ if (target.artboardId) void postConvertArtboardToAbsolute(target.artboardId);
2164
+ },
2165
+ },
2166
+ ],
1692
2167
  [
1693
2168
  // DDR-148 — only meaningful (and only enabled) on a video-comp
1694
2169
  // artboard; scopes + opens the shell's Timeline dock.
@@ -1771,7 +2246,7 @@ function buildRegistry(deps: {
1771
2246
  onSelect: () => distributeArtboards('y'),
1772
2247
  },
1773
2248
  ],
1774
- [themeItem],
2249
+ [themeItem, kindItem, duplicateAtBreakpointItem],
1775
2250
  [
1776
2251
  {
1777
2252
  id: 'delete-artboard',
@@ -1874,6 +2349,43 @@ export function revealElementViaCamera(
1874
2349
  if (dx !== 0 || dy !== 0) controller.panBy(dx, dy);
1875
2350
  }
1876
2351
 
2352
+ // ─────────────────────────────────────────────────────────────────────────────
2353
+ // feature-4 T7b — locked-layer enforcement. The shell owns the padlock UI +
2354
+ // view.json persistence; the canvas only needs the LIVE set to refuse
2355
+ // select/drag on locked elements. Window-level (not module `let`) so it
2356
+ // survives soft HMR re-evaluation, same reasoning as __maudeLastLayersArt.
2357
+ function lockedKeySet(): Set<string> {
2358
+ const w = window as unknown as { __maudeLockedKeys?: Set<string> };
2359
+ if (!w.__maudeLockedKeys) {
2360
+ w.__maudeLockedKeys = new Set(
2361
+ Array.isArray(
2362
+ (window as unknown as { __canvas_meta__?: { locked?: unknown } }).__canvas_meta__?.locked
2363
+ )
2364
+ ? ((window as unknown as { __canvas_meta__?: { locked?: string[] } }).__canvas_meta__
2365
+ ?.locked as string[])
2366
+ : []
2367
+ );
2368
+ }
2369
+ return w.__maudeLockedKeys;
2370
+ }
2371
+
2372
+ /** Is the element a locked layer? Matches by `cdId:occurrenceIndex` within the
2373
+ * artboard body (same occurrence math the Layers rows use). */
2374
+ function isLockedElement(el: Element | null, cdId: string | null): boolean {
2375
+ if (!el || !cdId) return false;
2376
+ const locked = lockedKeySet();
2377
+ if (locked.size === 0) return false;
2378
+ const body = el.closest('.dc-artboard-body');
2379
+ if (!body) return false;
2380
+ try {
2381
+ const same = Array.from(body.querySelectorAll(`[data-cd-id="${CSS.escape(cdId)}"]`));
2382
+ const idx = same.indexOf(el);
2383
+ return locked.has(`${cdId}:${idx < 0 ? 0 : idx}`);
2384
+ } catch {
2385
+ return false;
2386
+ }
2387
+ }
2388
+
1877
2389
  // ─────────────────────────────────────────────────────────────────────────────
1878
2390
  // Router wire-up
1879
2391
 
@@ -1938,6 +2450,34 @@ function CanvasRouter({
1938
2450
  // consumes.
1939
2451
  const chromeCtx = useChromeVisibility();
1940
2452
 
2453
+ // Dogfood follow-up (print artboards) — self-seed the per-canvas persisted
2454
+ // overlay visibility (view.json `overlays.guides`/`overlays.print`, GET-
2455
+ // merged into `window.__canvas_meta__` by _shell.html before mount) into
2456
+ // the chrome store, once per mount. This seed originally lived in
2457
+ // DesignCanvasInner (canvas-lib.tsx) — but that component renders
2458
+ // CanvasShell as its CHILD, and ChromeVisibilityProvider mounts INSIDE
2459
+ // CanvasShell, so DesignCanvasInner's own useChromeVisibility() was always
2460
+ // null and the seed silently never ran (dead since the foundation feature;
2461
+ // the generic `guides` toggle had no UI yet, so nothing surfaced it —
2462
+ // "Show print guides" is its first real consumer). CanvasRouter sits
2463
+ // inside the provider (it's the view-chrome bridge), so the seed works
2464
+ // here. Absent keys stay HIDDEN (the T6 gotcha — an old view.json must
2465
+ // not suddenly paint guides nobody asked for).
2466
+ const overlaysSeededRef = useRef(false);
2467
+ useEffect(() => {
2468
+ if (overlaysSeededRef.current || !chromeCtx) return;
2469
+ overlaysSeededRef.current = true;
2470
+ const w = window as unknown as {
2471
+ __canvas_meta__?: { overlays?: Record<string, boolean> };
2472
+ };
2473
+ const overlays = w.__canvas_meta__?.overlays;
2474
+ if (!overlays) return;
2475
+ const patch: { guides?: boolean; print?: boolean } = {};
2476
+ if (overlays.guides === true) patch.guides = true;
2477
+ if (overlays.print === true) patch.print = true;
2478
+ if (Object.keys(patch).length > 0) chromeCtx.setChrome(patch);
2479
+ }, [chromeCtx]);
2480
+
1941
2481
  // Task A2 (feature-element-editing-robustness) — host-scroll-0 invariant.
1942
2482
  // `.dc-canvas` (overflow:hidden) and each `.dc-artboard-body` are the engine's
1943
2483
  // clip layers; the camera lives entirely in the `.dc-world` transform, and
@@ -1979,6 +2519,15 @@ function CanvasRouter({
1979
2519
 
1980
2520
  const getActiveTool = useCallback(() => tool, [tool]);
1981
2521
 
2522
+ // feature-4 (browse/move split) — a live ref to the active tool for the
2523
+ // long-lived capture listeners (dblclick) whose effects don't re-attach on a
2524
+ // tool change. In the `browse` tool every canvas gesture is a pure native
2525
+ // pass-through, so drill-select + inline text-edit must NOT fire — a
2526
+ // double-click there belongs to the mock (selecting text in a real input,
2527
+ // etc.). Press V for the Move (select) tool to edit.
2528
+ const dblToolRef = useRef(tool);
2529
+ dblToolRef.current = tool;
2530
+
1982
2531
  const applyHover = useCallback(() => {
1983
2532
  hoverRafRef.current = null;
1984
2533
  const pending = pendingHoverRef.current;
@@ -2022,6 +2571,83 @@ function CanvasRouter({
2022
2571
  if (typeof t === 'string') setTool(t as never);
2023
2572
  return;
2024
2573
  }
2574
+ // feature-4 (2026-07-20) — the Inspector's kind picker (shell-side) asks
2575
+ // the CANVAS to freeze-then-switch, because only the canvas can measure
2576
+ // boxes. Both outbound messages (convert request, kind request) leave in
2577
+ // order, so the shell's structuralWrite chain serializes them correctly
2578
+ // (freeze lands before a print-kind resize could move anything).
2579
+ if (m.dgn === 'freeze-and-set-kind') {
2580
+ // SECURITY (ethical-hacker) — accept ONLY from the parent shell, never
2581
+ // from a canvas self-post or a sibling canvas iframe (all canvases share
2582
+ // one canvasOrigin, so any frame can otherwise forge this cross-frame —
2583
+ // confused-deputy: it drives a privileged convert-to-absolute + artboard
2584
+ // kind write). Same gate as apply-style/record-edit above.
2585
+ if (e.source !== window.parent) return;
2586
+ const mm = m as {
2587
+ artboardId?: string;
2588
+ kind?: string | null;
2589
+ freeze?: boolean;
2590
+ ask?: boolean;
2591
+ };
2592
+ if (typeof mm.artboardId === 'string' && mm.artboardId) {
2593
+ const artboardId = mm.artboardId;
2594
+ const kind = typeof mm.kind === 'string' ? mm.kind : null;
2595
+ void (async () => {
2596
+ // Dogfood round 5 — `ask: true` = the CANVAS shows the freeze
2597
+ // offer itself (canvasConfirm; the shell's window.confirm is a
2598
+ // silent no-op in the Tauri WKWebView).
2599
+ let freeze = mm.freeze !== false;
2600
+ if (mm.ask === true) {
2601
+ freeze = await canvasConfirm(
2602
+ `${kind === 'print' ? 'Print' : 'Digital'} artboards work best with freely movable elements.\nAlso freeze the current layout (convert everything to position: absolute, removing invisible layout wrappers)? One-way change — ⌘Z right after still reverts it.`,
2603
+ {
2604
+ title: kind === 'print' ? 'Switch to Print' : 'Switch to Digital',
2605
+ confirmLabel: 'Switch + freeze',
2606
+ cancelLabel: 'Just switch',
2607
+ }
2608
+ );
2609
+ }
2610
+ if (freeze) {
2611
+ const convert = (
2612
+ window as unknown as {
2613
+ __maudeConvertArtboard?: (
2614
+ id: string,
2615
+ o?: { skipConfirm?: boolean }
2616
+ ) => Promise<void>;
2617
+ }
2618
+ ).__maudeConvertArtboard;
2619
+ await convert?.(artboardId, { skipConfirm: true });
2620
+ }
2621
+ try {
2622
+ window.parent.postMessage(
2623
+ { dgn: 'set-artboard-kind-request', artboardId, kind },
2624
+ '*'
2625
+ );
2626
+ } catch {
2627
+ /* detached */
2628
+ }
2629
+ })();
2630
+ }
2631
+ return;
2632
+ }
2633
+ // feature-4 (2026-07-19) — generic shell→canvas result toast (convert /
2634
+ // detach success + failure feedback; the zero-visual-delta convert
2635
+ // otherwise LOOKS like nothing happened).
2636
+ if (m.dgn === 'op-toast') {
2637
+ const msg = (m as { message?: string }).message;
2638
+ if (typeof msg === 'string' && msg) showCanvasToast(msg.slice(0, 300));
2639
+ return;
2640
+ }
2641
+ // feature-4 T7b — live locked-layer set from the shell's Layers panel.
2642
+ if (m.dgn === 'locked-set') {
2643
+ const arr = (m as { locked?: unknown }).locked;
2644
+ if (Array.isArray(arr)) {
2645
+ const s = lockedKeySet();
2646
+ s.clear();
2647
+ for (const k of arr) if (typeof k === 'string') s.add(k);
2648
+ }
2649
+ return;
2650
+ }
2025
2651
  // Plan C — Edit menu (shell) bridges to the in-canvas undo stack.
2026
2652
  if (m.dgn === 'undo') {
2027
2653
  void undoStack.undo();
@@ -2037,29 +2663,52 @@ function CanvasRouter({
2037
2663
  // request-layers re-walks + posts the tree for an artboard.
2038
2664
  if (m.dgn === 'select-by-id') {
2039
2665
  const mm = m as { id?: string; artboardId?: string | null; index?: number };
2040
- if (mm.id) {
2041
- const target = resolveSelectionEl(document, {
2042
- id: mm.id,
2043
- artboardId: mm.artboardId,
2044
- index: mm.index,
2045
- });
2046
- if (target) {
2047
- selSet.replace(
2048
- hoverTargetToSelection({
2049
- el: target,
2050
- cdId: mm.id,
2051
- artboardId: mm.artboardId ?? null,
2052
- } as HoverTarget)
2053
- );
2054
- // Task A1 — reveal THROUGH the camera, not host scroll. The old
2055
- // `scrollIntoView` scrolled the overflow:hidden `.dc-canvas` /
2056
- // `.dc-artboard-body` clip layers, desyncing the `.dc-world`
2057
- // transform (Bugs A + B). `revealElementViaCamera` pans the world
2058
- // the minimal amount only when the element is off-screen.
2059
- const host = hostRef.current;
2060
- if (host && zoomController) {
2061
- revealElementViaCamera(host, target as HTMLElement, zoomController);
2062
- }
2666
+ // Dogfood follow-up — a bare ARTBOARD selection (no data-cd-id, the
2667
+ // ArtboardKnobs panel's own case) has no `mm.id` to resolve by, so
2668
+ // the element branch below never ran for it: after a structural
2669
+ // artboard write (kind/print/style/hug/resize) the Inspector kept
2670
+ // showing the PRE-edit attrs snapshot (e.g. "Digital" right after
2671
+ // switching to "Print") until the user manually re-clicked the
2672
+ // artboard. Resolve by the SAME `[data-dc-screen="<id>"]` selector
2673
+ // ArtboardKnobs itself reads its selection from (dom-selection.ts
2674
+ // `hoverTargetToSelection` "2. data-dc-screen" resolution order) when
2675
+ // there's no cdId to anchor on.
2676
+ const target = mm.id
2677
+ ? resolveSelectionEl(document, { id: mm.id, artboardId: mm.artboardId, index: mm.index })
2678
+ : mm.artboardId
2679
+ ? resolveSelectionEl(document, {
2680
+ selector: `[data-dc-screen="${CSS.escape(mm.artboardId)}"]`,
2681
+ })
2682
+ : null;
2683
+ if (target) {
2684
+ // feature-4 dogfood fix (2026-07-19) — an IDEMPOTENT re-select (the
2685
+ // shell's halo-restore ladder re-posts the same selection at 50…5000
2686
+ // ms; artboard-resync does the same) must NOT re-reveal: the user may
2687
+ // be panning elsewhere mid-ladder, and every re-post used to yank the
2688
+ // camera back to the selection ("pan skoči zpět na selected objekt").
2689
+ // Reveal only when the selection actually CHANGES.
2690
+ const cur = selSet.selected;
2691
+ const one = cur.length === 1 ? cur[0] : null;
2692
+ const sameSelection = mm.id
2693
+ ? !!one &&
2694
+ one.id === mm.id &&
2695
+ (mm.index == null || one.index == null || one.index === mm.index)
2696
+ : !!one && !one.id && one.artboardId === (mm.artboardId ?? null);
2697
+ selSet.replace(
2698
+ hoverTargetToSelection({
2699
+ el: target,
2700
+ cdId: mm.id,
2701
+ artboardId: mm.artboardId ?? null,
2702
+ } as HoverTarget)
2703
+ );
2704
+ // Task A1 — reveal THROUGH the camera, not host scroll. The old
2705
+ // `scrollIntoView` scrolled the overflow:hidden `.dc-canvas` /
2706
+ // `.dc-artboard-body` clip layers, desyncing the `.dc-world`
2707
+ // transform (Bugs A + B). `revealElementViaCamera` pans the world
2708
+ // the minimal amount only when the element is off-screen.
2709
+ const host = hostRef.current;
2710
+ if (host && zoomController && !sameSelection) {
2711
+ revealElementViaCamera(host, target as HTMLElement, zoomController);
2063
2712
  }
2064
2713
  }
2065
2714
  return;
@@ -2188,13 +2837,57 @@ function CanvasRouter({
2188
2837
  }
2189
2838
  // Shell View-menu chrome toggles + Presentation Mode. Only the fields the
2190
2839
  // shell sends are merged, so a `{ present: true }` enter keeps the user's
2191
- // individual minimap/zoom toggles for the eventual exit.
2840
+ // individual minimap/zoom toggles for the eventual exit. `guides` (T6) is
2841
+ // the odd one out — per-CANVAS persisted state, not a global pref — but
2842
+ // rides the same live-broadcast channel; the shell PATCHes view.json
2843
+ // separately (see setGuidesVisible in app.jsx).
2192
2844
  if (m.dgn === 'view-chrome' && chromeCtx) {
2193
- const mm = m as { minimap?: boolean; zoom?: boolean; present?: boolean };
2194
- const patch: { minimap?: boolean; zoom?: boolean; present?: boolean } = {};
2845
+ const mm = m as {
2846
+ minimap?: boolean;
2847
+ zoom?: boolean;
2848
+ present?: boolean;
2849
+ guides?: boolean;
2850
+ print?: boolean;
2851
+ };
2852
+ const patch: {
2853
+ minimap?: boolean;
2854
+ zoom?: boolean;
2855
+ present?: boolean;
2856
+ guides?: boolean;
2857
+ print?: boolean;
2858
+ } = {};
2195
2859
  if (typeof mm.minimap === 'boolean') patch.minimap = mm.minimap;
2196
2860
  if (typeof mm.zoom === 'boolean') patch.zoom = mm.zoom;
2197
2861
  if (typeof mm.present === 'boolean') patch.present = mm.present;
2862
+ if (typeof mm.guides === 'boolean') patch.guides = mm.guides;
2863
+ // feature-2-print-artboards T3 — "Show print guides" (bleed/trim/
2864
+ // margin overlay). Same per-canvas-persisted shape as `guides`.
2865
+ if (typeof mm.print === 'boolean') patch.print = mm.print;
2866
+ // Dogfood follow-up — mirror `guides`/`print` into the in-iframe
2867
+ // `window.__canvas_meta__.overlays` snapshot, exactly like the
2868
+ // existing viewport/layout mirror in canvas-lib.tsx's onSettle (DDR-
2869
+ // 138 comment: "populated ONCE at page load — so after a settle, a
2870
+ // soft HMR remount (any module edit: text, reorder, agent edit)
2871
+ // would re-read the STALE page-load [value]"). Without this, a
2872
+ // structural edit AFTER toggling guides on (e.g. a kind switch,
2873
+ // which is exactly such an edit) remounts DesignCanvasInner, its
2874
+ // `guidesSeededRef` self-seed effect re-fires, and reads the stale
2875
+ // page-load overlays snapshot — silently reverting a live-toggled
2876
+ // guide back to hidden even though the View-menu checkbox still
2877
+ // shows it checked. Same class of bug DDR-138 fixed for the camera;
2878
+ // this is its `overlays` counterpart.
2879
+ if (patch.guides !== undefined || patch.print !== undefined) {
2880
+ const w = window as unknown as {
2881
+ __canvas_meta__?: { overlays?: Record<string, boolean> };
2882
+ };
2883
+ if (w.__canvas_meta__) {
2884
+ w.__canvas_meta__.overlays = {
2885
+ ...w.__canvas_meta__.overlays,
2886
+ ...(patch.guides !== undefined ? { guides: patch.guides } : {}),
2887
+ ...(patch.print !== undefined ? { print: patch.print } : {}),
2888
+ };
2889
+ }
2890
+ }
2198
2891
  chromeCtx.setChrome(patch);
2199
2892
  return;
2200
2893
  }
@@ -2303,7 +2996,12 @@ function CanvasRouter({
2303
2996
  // AND the annotation Text-tool's click-through (maude:enter-text-edit,
2304
2997
  // dispatched from annotations-layer.tsx when its own click lands on an
2305
2998
  // existing editable element) share one entry point.
2306
- function enterEditModeAt(stamped: HTMLElement, clientX: number, clientY: number): void {
2999
+ function enterEditModeAt(
3000
+ stamped: HTMLElement,
3001
+ clientX: number,
3002
+ clientY: number,
3003
+ opts?: { selectAll?: boolean }
3004
+ ): void {
2307
3005
  if (editing) return;
2308
3006
  editing = stamped;
2309
3007
  original = stamped.textContent ?? '';
@@ -2312,31 +3010,55 @@ function CanvasRouter({
2312
3010
  stamped.addEventListener('blur', onBlur, true);
2313
3011
  stamped.addEventListener('keydown', onKey, true);
2314
3012
  stamped.focus();
2315
- // Unified placement chain (text-caret.ts) — the annotation editors use
2316
- // the exact same helper, so caret-at-click can never diverge per surface.
2317
- placeCaretAt(stamped, window, { x: clientX, y: clientY });
3013
+ if (opts?.selectAll) {
3014
+ // feature-4 text-gestures (user steer 2026-07-20) — the DBLCLICK entry
3015
+ // selects ALL text (Figma: reaching the leaf via the drill ladder puts
3016
+ // the whole string under your fingers, ready to overwrite). Subsequent
3017
+ // clicks/dblclicks inside the editor are native (the router bails on
3018
+ // editable targets): click = caret, dblclick = word, triple = all.
3019
+ try {
3020
+ const sel = window.getSelection();
3021
+ const range = document.createRange();
3022
+ range.selectNodeContents(stamped);
3023
+ sel?.removeAllRanges();
3024
+ sel?.addRange(range);
3025
+ } catch {
3026
+ placeCaretAt(stamped, window, { x: clientX, y: clientY });
3027
+ }
3028
+ } else {
3029
+ // Unified placement chain (text-caret.ts) — the annotation editors use
3030
+ // the exact same helper, so caret-at-click can never diverge per surface.
3031
+ placeCaretAt(stamped, window, { x: clientX, y: clientY });
3032
+ }
2318
3033
  caretDispose = mountCaret(stamped, window);
2319
3034
  }
2320
3035
  const onDbl = (e: MouseEvent): void => {
2321
3036
  if (editing) return;
3037
+ // feature-4 — browse is a pure pass-through: no drill-select, no inline
3038
+ // text-edit. The mock owns its own double-clicks. Press V to edit.
3039
+ if (dblToolRef.current === 'browse') return;
2322
3040
  const t = e.target as HTMLElement | null;
2323
3041
  const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
2324
3042
  if (!stamped) return;
2325
3043
  const kids = Array.from(stamped.childNodes);
2326
3044
  const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
2327
- if (!isLeafText) {
2328
- // Task L6 — deep-select: a container (anything that isn't leaf-text)
2329
- // double-clicked here used to just bail — this is the exact gap the
2330
- // plan's deep-select task targets. Drill ONE level past whatever's
2331
- // currently selected along this click's own stamped-ancestor chain
2332
- // (Figma parity: repeated double-clicks walk progressively deeper);
2333
- // default to the outermost rung when nothing in the chain is
2334
- // currently selected. Mutually exclusive with text-edit below by
2335
- // construction — same capture-phase listener, same branch gate.
3045
+ // Task L6 + feature-4 dogfood fix (2026-07-19) — the Figma ordering: each
3046
+ // double-click drills ONE level along the clicked element's stamped
3047
+ // ancestor chain; text edit opens only when the LEAF ITSELF is already
3048
+ // selected (the drill has reached it). Previously a dblclick on leaf
3049
+ // text jumped straight to the editor from any depth, so the drill ladder
3050
+ // never ran on text-bearing targets.
3051
+ const sel0 = selSet.selected;
3052
+ const currentId = sel0.length === 1 ? sel0[0]?.id : undefined;
3053
+ const leafReached = isLeafText && currentId === stamped.getAttribute('data-cd-id');
3054
+ if (!leafReached) {
3055
+ // Drill ONE level past whatever's currently selected along this
3056
+ // click's own stamped-ancestor chain (repeated double-clicks walk
3057
+ // progressively deeper; the chain's last rung is the clicked element
3058
+ // itself); default to the outermost rung when nothing in the chain is
3059
+ // currently selected.
2336
3060
  const chain = stampedChainToBody(stamped);
2337
3061
  if (chain.length > 0) {
2338
- const sel = selSet.selected;
2339
- const currentId = sel.length === 1 ? sel[0]?.id : undefined;
2340
3062
  const curIdx = currentId
2341
3063
  ? chain.findIndex((n) => n.getAttribute('data-cd-id') === currentId)
2342
3064
  : -1;
@@ -2368,7 +3090,7 @@ function CanvasRouter({
2368
3090
  );
2369
3091
  return;
2370
3092
  }
2371
- enterEditModeAt(stamped, e.clientX, e.clientY);
3093
+ enterEditModeAt(stamped, e.clientX, e.clientY, { selectAll: true });
2372
3094
  };
2373
3095
  // Dogfood fix — the annotation Text tool's click-through: clicking an
2374
3096
  // existing editable element with the Text (T) tool active should edit
@@ -2442,6 +3164,39 @@ function CanvasRouter({
2442
3164
  // it actually captured something.
2443
3165
  return;
2444
3166
  }
3167
+ // feature-4 T7b — a locked layer can't be selected from the canvas
3168
+ // (the Layers panel row still can, so it stays reachable to unlock).
3169
+ if (isLockedElement(target.el, target.cdId)) return;
3170
+ // feature-4 escape hatch (user steer 2026-07-19) — a Cmd+click select
3171
+ // from BROWSE means "I want to edit now": flip to the Move (select)
3172
+ // tool so the follow-up gestures (drag, dblclick drill, keyboard)
3173
+ // work without a separate V press.
3174
+ if (tool === 'browse') setTool('move');
3175
+ // feature-4 dogfood fix (2026-07-19) — Figma's "entered group" context:
3176
+ // a bare (top-mode) click INSIDE the currently selected element keeps
3177
+ // that selection instead of resetting to the top-level ancestor.
3178
+ // Without this the dblclick drill ladder could never descend past one
3179
+ // level — the dblclick's own pointerdown(detail:2) re-selected the top
3180
+ // object right before the drill ran. Clicking OUTSIDE the current
3181
+ // selection (a sibling, empty space) still re-selects normally.
3182
+ if (!deep && mode === 'replace') {
3183
+ const cur = selSet.selected;
3184
+ const one = cur.length === 1 ? cur[0] : null;
3185
+ if (one?.id) {
3186
+ const curEl = resolveSelectionEl(document, one);
3187
+ const hitEl = document.elementFromPoint(clientX, clientY);
3188
+ if (
3189
+ curEl &&
3190
+ hitEl &&
3191
+ curEl !== target.el &&
3192
+ curEl.contains(hitEl) &&
3193
+ target.el &&
3194
+ target.el.contains(curEl)
3195
+ ) {
3196
+ return; // stay in the entered-group context
3197
+ }
3198
+ }
3199
+ }
2445
3200
  const sel = hoverTargetToSelection(target);
2446
3201
  if (mode === 'replace') selSet.replace(sel);
2447
3202
  else selSet.add(sel);
@@ -2506,6 +3261,7 @@ function CanvasRouter({
2506
3261
  <SelectionHalos />
2507
3262
  <ElementResizeOverlay />
2508
3263
  <SpacingHandlesOverlay />
3264
+ <GridTrackHandlesOverlay />
2509
3265
  <ReorderDrag />
2510
3266
  <LayersLiveSync />
2511
3267
  <GroupBbox />
@@ -3326,6 +4082,9 @@ function ReorderDrag() {
3326
4082
  if (!cdId || !one) return;
3327
4083
  const el = resolveSelectionEl(document, one) as HTMLElement | null;
3328
4084
  if (!el) return;
4085
+ // feature-4 T7b — a locked layer never drags (it can be selected from
4086
+ // the Layers panel, but the canvas won't move it).
4087
+ if (isLockedElement(el, cdId)) return;
3329
4088
  const t = e.target;
3330
4089
  if (!(t instanceof Node) || !el.contains(t)) return;
3331
4090
  // Candidate — do NOT claim yet (a plain click must still work). We only