@1agh/maude 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  3. package/apps/studio/acp/bridge.ts +121 -6
  4. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  5. package/apps/studio/annotations-layer.tsx +42 -0
  6. package/apps/studio/api.ts +417 -2
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  13. package/apps/studio/bin/curl-local.sh +28 -0
  14. package/apps/studio/build.ts +1 -1
  15. package/apps/studio/canvas-cursors.ts +6 -0
  16. package/apps/studio/canvas-edit.ts +678 -11
  17. package/apps/studio/canvas-icons.tsx +13 -0
  18. package/apps/studio/canvas-lib.tsx +3 -0
  19. package/apps/studio/canvas-shell.tsx +646 -26
  20. package/apps/studio/client/app.jsx +898 -52
  21. package/apps/studio/client/export-center.jsx +7 -6
  22. package/apps/studio/client/github.js +11 -4
  23. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  24. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  25. package/apps/studio/contextual-toolbar.tsx +5 -3
  26. package/apps/studio/dist/client.bundle.js +1103 -1103
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  29. package/apps/studio/dist/styles.css +1 -1
  30. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  31. package/apps/studio/exporters/pdf.ts +35 -1
  32. package/apps/studio/git/service.ts +4 -1
  33. package/apps/studio/grid-track-handles.ts +179 -0
  34. package/apps/studio/handoff.ts +35 -0
  35. package/apps/studio/http.ts +118 -0
  36. package/apps/studio/input-router.tsx +73 -17
  37. package/apps/studio/paths.ts +37 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  39. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  40. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  41. package/apps/studio/test/browse-posture.test.tsx +107 -0
  42. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  43. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  45. package/apps/studio/test/comment-mount.test.ts +2 -1
  46. package/apps/studio/test/component-map.test.ts +48 -0
  47. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  48. package/apps/studio/test/detach-component.test.ts +94 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  50. package/apps/studio/test/element-structural-api.test.ts +74 -0
  51. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  52. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  53. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  54. package/apps/studio/test/handoff.test.ts +48 -0
  55. package/apps/studio/test/input-router.test.ts +82 -8
  56. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  57. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  58. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  59. package/apps/studio/tool-palette.tsx +3 -1
  60. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  61. package/apps/studio/use-element-resize.tsx +3 -1
  62. package/apps/studio/use-grid-track-handles.tsx +364 -0
  63. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  64. package/apps/studio/use-tool-mode.tsx +30 -3
  65. package/apps/studio/web-overlay-content.tsx +52 -0
  66. package/apps/studio/whats-new.json +27 -0
  67. package/cli/bin/maude.mjs +1 -0
  68. package/cli/commands/design.mjs +16 -0
  69. package/cli/commands/init.mjs +80 -3
  70. package/cli/commands/kg.mjs +368 -0
  71. package/cli/commands/kg.test.mjs +118 -0
  72. package/cli/lib/ddr-to-kgai.mjs +648 -0
  73. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  74. package/cli/lib/flow-design-integration.test.mjs +2 -2
  75. package/cli/lib/gitignore-block.mjs +4 -0
  76. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  77. package/package.json +8 -8
  78. package/plugins/design/dependencies.json +17 -0
  79. package/plugins/design/templates/_shell.html +4 -0
  80. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  81. package/plugins/flow/dependencies.json +17 -0
@@ -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
  }
@@ -1450,15 +1509,45 @@ function buildRegistry(deps: {
1450
1509
  {
1451
1510
  id: 'kind-digital',
1452
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.
1453
1516
  onSelect: (target) => {
1454
- if (target.artboardId) postArtboardKindRequest(target.artboardId, null);
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
+ })();
1455
1531
  },
1456
1532
  },
1457
1533
  {
1458
1534
  id: 'kind-print',
1459
1535
  label: 'Print',
1460
1536
  onSelect: (target) => {
1461
- if (target.artboardId) postArtboardKindRequest(target.artboardId, 'print');
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
+ })();
1462
1551
  },
1463
1552
  },
1464
1553
  {
@@ -1478,6 +1567,311 @@ function buildRegistry(deps: {
1478
1567
  ],
1479
1568
  };
1480
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
+
1481
1875
  return {
1482
1876
  element: [
1483
1877
  [
@@ -1657,6 +2051,18 @@ function buildRegistry(deps: {
1657
2051
  }
1658
2052
  },
1659
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
+ },
1660
2066
  ],
1661
2067
  [exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
1662
2068
  [
@@ -1746,6 +2152,18 @@ function buildRegistry(deps: {
1746
2152
  fitItem,
1747
2153
  resetItem,
1748
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
+ ],
1749
2167
  [
1750
2168
  // DDR-148 — only meaningful (and only enabled) on a video-comp
1751
2169
  // artboard; scopes + opens the shell's Timeline dock.
@@ -1828,7 +2246,7 @@ function buildRegistry(deps: {
1828
2246
  onSelect: () => distributeArtboards('y'),
1829
2247
  },
1830
2248
  ],
1831
- [themeItem, kindItem],
2249
+ [themeItem, kindItem, duplicateAtBreakpointItem],
1832
2250
  [
1833
2251
  {
1834
2252
  id: 'delete-artboard',
@@ -1931,6 +2349,43 @@ export function revealElementViaCamera(
1931
2349
  if (dx !== 0 || dy !== 0) controller.panBy(dx, dy);
1932
2350
  }
1933
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
+
1934
2389
  // ─────────────────────────────────────────────────────────────────────────────
1935
2390
  // Router wire-up
1936
2391
 
@@ -2064,6 +2519,15 @@ function CanvasRouter({
2064
2519
 
2065
2520
  const getActiveTool = useCallback(() => tool, [tool]);
2066
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
+
2067
2531
  const applyHover = useCallback(() => {
2068
2532
  hoverRafRef.current = null;
2069
2533
  const pending = pendingHoverRef.current;
@@ -2107,6 +2571,83 @@ function CanvasRouter({
2107
2571
  if (typeof t === 'string') setTool(t as never);
2108
2572
  return;
2109
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
+ }
2110
2651
  // Plan C — Edit menu (shell) bridges to the in-canvas undo stack.
2111
2652
  if (m.dgn === 'undo') {
2112
2653
  void undoStack.undo();
@@ -2140,6 +2681,19 @@ function CanvasRouter({
2140
2681
  })
2141
2682
  : null;
2142
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);
2143
2697
  selSet.replace(
2144
2698
  hoverTargetToSelection({
2145
2699
  el: target,
@@ -2153,7 +2707,7 @@ function CanvasRouter({
2153
2707
  // transform (Bugs A + B). `revealElementViaCamera` pans the world
2154
2708
  // the minimal amount only when the element is off-screen.
2155
2709
  const host = hostRef.current;
2156
- if (host && zoomController) {
2710
+ if (host && zoomController && !sameSelection) {
2157
2711
  revealElementViaCamera(host, target as HTMLElement, zoomController);
2158
2712
  }
2159
2713
  }
@@ -2442,7 +2996,12 @@ function CanvasRouter({
2442
2996
  // AND the annotation Text-tool's click-through (maude:enter-text-edit,
2443
2997
  // dispatched from annotations-layer.tsx when its own click lands on an
2444
2998
  // existing editable element) share one entry point.
2445
- 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 {
2446
3005
  if (editing) return;
2447
3006
  editing = stamped;
2448
3007
  original = stamped.textContent ?? '';
@@ -2451,31 +3010,55 @@ function CanvasRouter({
2451
3010
  stamped.addEventListener('blur', onBlur, true);
2452
3011
  stamped.addEventListener('keydown', onKey, true);
2453
3012
  stamped.focus();
2454
- // Unified placement chain (text-caret.ts) — the annotation editors use
2455
- // the exact same helper, so caret-at-click can never diverge per surface.
2456
- 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
+ }
2457
3033
  caretDispose = mountCaret(stamped, window);
2458
3034
  }
2459
3035
  const onDbl = (e: MouseEvent): void => {
2460
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;
2461
3040
  const t = e.target as HTMLElement | null;
2462
3041
  const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
2463
3042
  if (!stamped) return;
2464
3043
  const kids = Array.from(stamped.childNodes);
2465
3044
  const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
2466
- if (!isLeafText) {
2467
- // Task L6 — deep-select: a container (anything that isn't leaf-text)
2468
- // double-clicked here used to just bail — this is the exact gap the
2469
- // plan's deep-select task targets. Drill ONE level past whatever's
2470
- // currently selected along this click's own stamped-ancestor chain
2471
- // (Figma parity: repeated double-clicks walk progressively deeper);
2472
- // default to the outermost rung when nothing in the chain is
2473
- // currently selected. Mutually exclusive with text-edit below by
2474
- // 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.
2475
3060
  const chain = stampedChainToBody(stamped);
2476
3061
  if (chain.length > 0) {
2477
- const sel = selSet.selected;
2478
- const currentId = sel.length === 1 ? sel[0]?.id : undefined;
2479
3062
  const curIdx = currentId
2480
3063
  ? chain.findIndex((n) => n.getAttribute('data-cd-id') === currentId)
2481
3064
  : -1;
@@ -2507,7 +3090,7 @@ function CanvasRouter({
2507
3090
  );
2508
3091
  return;
2509
3092
  }
2510
- enterEditModeAt(stamped, e.clientX, e.clientY);
3093
+ enterEditModeAt(stamped, e.clientX, e.clientY, { selectAll: true });
2511
3094
  };
2512
3095
  // Dogfood fix — the annotation Text tool's click-through: clicking an
2513
3096
  // existing editable element with the Text (T) tool active should edit
@@ -2581,6 +3164,39 @@ function CanvasRouter({
2581
3164
  // it actually captured something.
2582
3165
  return;
2583
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
+ }
2584
3200
  const sel = hoverTargetToSelection(target);
2585
3201
  if (mode === 'replace') selSet.replace(sel);
2586
3202
  else selSet.add(sel);
@@ -2645,6 +3261,7 @@ function CanvasRouter({
2645
3261
  <SelectionHalos />
2646
3262
  <ElementResizeOverlay />
2647
3263
  <SpacingHandlesOverlay />
3264
+ <GridTrackHandlesOverlay />
2648
3265
  <ReorderDrag />
2649
3266
  <LayersLiveSync />
2650
3267
  <GroupBbox />
@@ -3465,6 +4082,9 @@ function ReorderDrag() {
3465
4082
  if (!cdId || !one) return;
3466
4083
  const el = resolveSelectionEl(document, one) as HTMLElement | null;
3467
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;
3468
4088
  const t = e.target;
3469
4089
  if (!(t instanceof Node) || !el.contains(t)) return;
3470
4090
  // Candidate — do NOT claim yet (a plain click must still work). We only