@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,177 @@
1
+ // External-canvas list watcher.
2
+ //
3
+ // The browser file tree re-reads `/_index-data` only when it receives a
4
+ // `canvas-list-update` WS message. Until now that message was emitted ONLY by
5
+ // the dev-server's own create/delete API (`api.ts` createCanvas / deleteCanvas),
6
+ // so a canvas written straight to disk by something OUTSIDE the server — the ACP
7
+ // chat agent running `/design:new`, an agent `Write`, `git checkout`, a terminal
8
+ // `cp`/`rm` — never refreshed the tree. The file landed on disk, `fs:any` fired,
9
+ // the HMR/activity/git-status subscribers reacted, but the tree stayed stale
10
+ // until a full page reload. See `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
11
+ //
12
+ // This is the symmetric server-side counterpart: subscribe to `fs:any`, and when
13
+ // the set of openable canvas files under the configured canvas groups changes
14
+ // (add or remove), emit the same `canvas-list-update` the API does. The client's
15
+ // handler just calls `loadTree()` (a wholesale `/_index-data` re-read), so a
16
+ // nudge is all that's required — the diff makes it precise enough to skip every
17
+ // non-structural edit (a canvas body save, a `.meta.json` rewrite) and so avoid
18
+ // thrashing the tree on every `/design:edit`.
19
+ //
20
+ // Bun-native (DDR-009); the canvas listing reuses `findHtmlFiles` and the slug
21
+ // reuses `canvasSlugFromRel`, both from api.ts — the single source of truth that
22
+ // also backs `/_index-data` and the UI create/delete emits (no duplicated
23
+ // SKIP_DIRS / group rules to drift; DDR-115 runtime-state exclusions come along
24
+ // for free because `findHtmlFiles` already skips `_`-prefixed + SKIP_DIRS paths).
25
+
26
+ import path from 'node:path';
27
+
28
+ import { canvasSlugFromRel, findHtmlFiles, SKIP_DIRS } from './api.ts';
29
+ import type { Context } from './context.ts';
30
+
31
+ /** Burst-collapse window. A new canvas writes both `.tsx` and `.meta.json`; a
32
+ * git checkout touches many files at once — one snapshot per quiet window. */
33
+ export const CANVAS_LIST_DEBOUNCE_MS = 150;
34
+
35
+ /**
36
+ * Cheap gate: is this `fs:any` path worth a (debounced) full recompute? True
37
+ * only for an openable canvas file (`.tsx` / `.html`) that lives under one of
38
+ * the configured canvas groups and carries no `_`-prefixed or SKIP_DIRS segment.
39
+ * A loose gate just means an occasional needless recompute — never a wrong emit,
40
+ * since `snapshot()` is authoritative. Input is design-root-relative (the shape
41
+ * fs-watch emits, e.g. `ui/project/Pricing.tsx`).
42
+ *
43
+ * Pure — no disk access. Accepts back-slash or forward-slash input.
44
+ */
45
+ export function isCanvasCandidate(rel: string, groupPaths: string[]): boolean {
46
+ if (typeof rel !== 'string') return false;
47
+ const p = rel.replace(/\\/g, '/').replace(/^\/+/, '');
48
+ if (!p) return false;
49
+ const low = p.toLowerCase();
50
+ if (!low.endsWith('.tsx') && !low.endsWith('.html')) return false;
51
+ const segs = p.split('/');
52
+ for (const s of segs) {
53
+ if (!s) return false;
54
+ if (s.startsWith('_')) return false;
55
+ if (SKIP_DIRS.has(s)) return false;
56
+ }
57
+ return groupPaths.some((gp) => {
58
+ const g = gp.replace(/^\/+|\/+$/g, '');
59
+ return !!g && (p === g || p.startsWith(`${g}/`));
60
+ });
61
+ }
62
+
63
+ export interface CanvasListWatch {
64
+ /** Resolves once the initial canvas set has been seeded (no emit). */
65
+ readonly ready: Promise<void>;
66
+ /** Test seam — force a recompute + diff + emit. Serialized after the seed and
67
+ * any in-flight refresh, so a create observed between calls always diffs
68
+ * against the pre-create set. */
69
+ refresh(): Promise<void>;
70
+ /** Current known openable-canvas set (design-rel-prefixed). For assertions. */
71
+ readonly known: ReadonlySet<string>;
72
+ stop(): void;
73
+ }
74
+
75
+ export interface CanvasListWatchOptions {
76
+ debounceMs?: number;
77
+ }
78
+
79
+ export function createCanvasListWatch(
80
+ ctx: Context,
81
+ opts: CanvasListWatchOptions = {}
82
+ ): CanvasListWatch {
83
+ const debounceMs = opts.debounceMs ?? CANVAS_LIST_DEBOUNCE_MS;
84
+ // Mirror /_index-data's group set exactly (cfg.canvasGroups) — NOT api.ts's
85
+ // create-allowlist, which additionally permits cfg.newCanvasDir. On the
86
+ // default config newCanvasDir IS a group; a config pointing it outside
87
+ // canvasGroups would have the canvas absent from /_index-data too, so the
88
+ // watcher stays consistent with what the tree can actually show.
89
+ const groupPaths = (ctx.cfg.canvasGroups ?? []).map((g) => g.path);
90
+
91
+ // null = not yet seeded; the first refresh captures the baseline and emits
92
+ // nothing (canvases already on disk at boot aren't "added").
93
+ let known: Set<string> | null = null;
94
+ let chain: Promise<void> = Promise.resolve();
95
+ let pending: ReturnType<typeof setTimeout> | null = null;
96
+
97
+ async function snapshot(): Promise<Set<string>> {
98
+ const out = new Set<string>();
99
+ for (const g of ctx.cfg.canvasGroups ?? []) {
100
+ const groupAbs = path.join(ctx.paths.designRoot, g.path);
101
+ const groupRel = path.posix.join(ctx.paths.designRel, g.path);
102
+ let files: string[];
103
+ try {
104
+ files = await findHtmlFiles(groupAbs, groupRel);
105
+ } catch {
106
+ continue; // group dir missing / unreadable — treat as empty
107
+ }
108
+ for (const f of files) out.add(f);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ function emit(action: 'added' | 'removed', rel: string) {
114
+ // `rel` from findHtmlFiles is designRel-prefixed (`.design/ui/X.tsx`); strip
115
+ // the prefix so the wire shape matches api.ts's group-relative emit
116
+ // (`ui/X.tsx`). The client IGNORES the payload entirely — it just re-reads
117
+ // /_index-data — so rel/slug are advisory only. SECURITY TRIPWIRE: any FUTURE
118
+ // consumer of this event must treat rel/slug as ATTACKER-CONTROLLED (agent /
119
+ // `git checkout`-authored filenames) and must NOT feed them to a
120
+ // render/open/build sink without re-validating against /_index-data.
121
+ const prefix = `${ctx.paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
122
+ const relOut = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel;
123
+ ctx.bus.emit('canvas-list-update', {
124
+ action,
125
+ rel: relOut,
126
+ slug: canvasSlugFromRel(rel, ctx.paths.designRel),
127
+ });
128
+ }
129
+
130
+ async function doRefresh(): Promise<void> {
131
+ const next = await snapshot();
132
+ if (known === null) {
133
+ known = next; // seed — no emit
134
+ return;
135
+ }
136
+ for (const rel of next) if (!known.has(rel)) emit('added', rel);
137
+ for (const rel of known) if (!next.has(rel)) emit('removed', rel);
138
+ known = next;
139
+ }
140
+
141
+ // Serialize every recompute through one chain so the boot-time seed always
142
+ // runs first and concurrent snapshots can't race a create into a missed diff.
143
+ function refresh(): Promise<void> {
144
+ chain = chain.then(doRefresh, doRefresh);
145
+ return chain;
146
+ }
147
+
148
+ const ready = refresh(); // seed at construction
149
+
150
+ function schedule() {
151
+ if (pending) clearTimeout(pending);
152
+ pending = setTimeout(() => {
153
+ pending = null;
154
+ void refresh();
155
+ }, debounceMs);
156
+ }
157
+
158
+ const off = ctx.bus.on('fs:any', (rel: string) => {
159
+ if (!isCanvasCandidate(rel, groupPaths)) return;
160
+ schedule();
161
+ });
162
+
163
+ function stop() {
164
+ off();
165
+ if (pending) clearTimeout(pending);
166
+ pending = null;
167
+ }
168
+
169
+ return {
170
+ ready,
171
+ refresh,
172
+ get known() {
173
+ return known ?? new Set<string>();
174
+ },
175
+ stop,
176
+ };
177
+ }
@@ -82,6 +82,7 @@ import {
82
82
  useAnnotationSelectionOptional,
83
83
  } from './use-annotation-selection.tsx';
84
84
  import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
85
+ import { ChromeVisibilityProvider, useChromeVisibility } from './use-chrome-visibility.tsx';
85
86
  import { useCollab } from './use-collab.tsx';
86
87
  import { useCursorModifiers } from './use-cursor-modifiers.tsx';
87
88
  import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
@@ -302,6 +303,14 @@ const HALO_CSS = `
302
303
  -webkit-font-smoothing: subpixel-antialiased;
303
304
  font-smooth: always;
304
305
  }
306
+ /* Phase 12 (DDR-103) — inline text editing: the element being edited carries an
307
+ accent ring + caret. plaintext-only contenteditable; commit writes to source. */
308
+ [contenteditable].dc-text-editing {
309
+ outline: 2px solid var(--maude-hud-accent, #0d99ff);
310
+ outline-offset: 1px;
311
+ border-radius: 1px;
312
+ cursor: text;
313
+ }
305
314
  `.trim();
306
315
 
307
316
  function ensureHaloStyles(): void {
@@ -322,6 +331,104 @@ function ensureHaloStyles(): void {
322
331
  document.head.appendChild(s);
323
332
  }
324
333
 
334
+ // ─────────────────────────────────────────────────────────────────────────────
335
+ // Layers tree (Phase 12 Task 4, DDR-103). The shell's Layers tab is a browsable
336
+ // tree of the active artboard's stamped (`data-cd-id`) elements. We walk the
337
+ // artboard subtree in the iframe, serialize a nested tree, and post it to the
338
+ // shell; the shell renders it + posts back `select-by-id` / `highlight`.
339
+
340
+ interface LayerNode {
341
+ id: string;
342
+ tag: string;
343
+ label: string;
344
+ type: string;
345
+ /** Occurrence index of this id within the artboard — matches the resolver's
346
+ * `querySelectorAll([data-dc-screen=…] [data-cd-id=…])[index]`. */
347
+ index: number;
348
+ children: LayerNode[];
349
+ }
350
+
351
+ function kebabToTitle(s: string): string {
352
+ return s
353
+ .split('-')
354
+ .filter(Boolean)
355
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
356
+ .join(' ');
357
+ }
358
+
359
+ // Human label: data-dc-element (Title Case) → aria-label → role → tag(.class).
360
+ function layerLabel(el: Element): string {
361
+ const dc = el.getAttribute('data-dc-element');
362
+ if (dc) return kebabToTitle(dc);
363
+ const aria = el.getAttribute('aria-label');
364
+ if (aria) return aria.slice(0, 40);
365
+ const role = el.getAttribute('role');
366
+ if (role) return role;
367
+ const tag = el.tagName.toLowerCase();
368
+ const cls = realClasses(el).split(/\s+/).filter(Boolean)[0];
369
+ return cls ? `${tag}.${cls}` : tag;
370
+ }
371
+
372
+ function layerType(el: Element): string {
373
+ const tag = el.tagName.toLowerCase();
374
+ const role = el.getAttribute('role') ?? '';
375
+ const dc = el.getAttribute('data-dc-element') ?? '';
376
+ if (tag === 'button' || role === 'button' || /(^|-)(btn|cta|button)(-|$)/.test(dc))
377
+ return 'button';
378
+ if (/^h[1-6]$/.test(tag) || role === 'heading') return 'heading';
379
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return 'input';
380
+ if (tag === 'img' || tag === 'svg' || tag === 'picture' || tag === 'video') return 'image';
381
+ if (tag === 'a') return 'link';
382
+ if (tag === 'ul' || tag === 'ol' || tag === 'li') return 'list';
383
+ if (tag === 'nav') return 'nav';
384
+ if (tag === 'form') return 'form';
385
+ if (tag === 'p' || tag === 'span' || tag === 'label') return 'text';
386
+ return 'box';
387
+ }
388
+
389
+ function serializeArtboardTree(root: Element): LayerNode[] {
390
+ const seen = new Map<string, number>();
391
+ function walk(el: Element): LayerNode[] {
392
+ const out: LayerNode[] = [];
393
+ for (const child of Array.from(el.children)) {
394
+ // Skip dev-server overlay chrome (pins, halos) that lives inside the artboard.
395
+ if (child.closest('.dgn-pin, .dc-cv-halo, .dc-cv-group-bbox')) continue;
396
+ const cd = child.getAttribute('data-cd-id');
397
+ if (cd) {
398
+ const idx = seen.get(cd) ?? 0;
399
+ seen.set(cd, idx + 1);
400
+ out.push({
401
+ id: cd,
402
+ tag: child.tagName.toLowerCase(),
403
+ label: layerLabel(child),
404
+ type: layerType(child),
405
+ index: idx,
406
+ children: walk(child),
407
+ });
408
+ } else {
409
+ // Unstamped wrapper — descend so its stamped descendants still surface.
410
+ out.push(...walk(child));
411
+ }
412
+ }
413
+ return out;
414
+ }
415
+ return walk(root);
416
+ }
417
+
418
+ function postLayersTree(artboardId: string | null | undefined): void {
419
+ if (typeof document === 'undefined' || !artboardId) return;
420
+ const root = document.querySelector(`[data-dc-screen="${CSS.escape(artboardId)}"]`);
421
+ if (!root) return;
422
+ try {
423
+ window.parent.postMessage(
424
+ { dgn: 'layers-tree', artboardId, tree: serializeArtboardTree(root) },
425
+ '*'
426
+ );
427
+ } catch {
428
+ /* detached / cross-origin */
429
+ }
430
+ }
431
+
325
432
  // ─────────────────────────────────────────────────────────────────────────────
326
433
  // Shell
327
434
 
@@ -375,7 +482,9 @@ export function CanvasShell({
375
482
  <MaybeSelectionSetProvider>
376
483
  <AnnotationSelectionProvider>
377
484
  <AnnotationsVisibilityProvider>
378
- <CanvasCore hostRef={hostRef}>{children}</CanvasCore>
485
+ <ChromeVisibilityProvider>
486
+ <CanvasCore hostRef={hostRef}>{children}</CanvasCore>
487
+ </ChromeVisibilityProvider>
379
488
  </AnnotationsVisibilityProvider>
380
489
  </AnnotationSelectionProvider>
381
490
  </MaybeSelectionSetProvider>
@@ -434,7 +543,7 @@ function CanvasCore({
434
543
  if (!host) return;
435
544
  const onDbl = (e: MouseEvent) => {
436
545
  const t = e.target as Element | null;
437
- if (!t || !t.closest) return;
546
+ if (!t?.closest) return;
438
547
  // Floating chrome / overlays / drawn user content / any artboard
439
548
  // surface → leave alone. Only dblclick that lands on the canvas
440
549
  // background outside every artboard triggers `fit()`.
@@ -517,7 +626,7 @@ function CanvasCore({
517
626
  useEffect(() => {
518
627
  if (!collab) return;
519
628
  const first = selSet.selected[0];
520
- if (!first || !first.selector) {
629
+ if (!first?.selector) {
521
630
  collab.publishAwareness({ selection: null });
522
631
  return;
523
632
  }
@@ -947,7 +1056,7 @@ function applyArtboardFollowers(): void {
947
1056
  function buildRegistry(deps: {
948
1057
  controller: ViewportControllerHandle | null;
949
1058
  clearSelection: () => void;
950
- selSet: { selected: Selection[] };
1059
+ selSet: { selected: Selection[]; replace: (s: Selection | Selection[]) => void };
951
1060
  distributeArtboards: (axis: 'x' | 'y') => void;
952
1061
  alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
953
1062
  focusArtboard: (artboardId: string) => void;
@@ -1109,9 +1218,24 @@ function buildRegistry(deps: {
1109
1218
  id: 'inspect',
1110
1219
  label: 'Inspect',
1111
1220
  shortcut: '⌥I',
1112
- disabled: true,
1113
- onSelect: () => {
1114
- console.warn('[context-menu] TODO: tweaks panel for TSX canvases');
1221
+ onSelect: (target) => {
1222
+ // Phase 12 — select the right-clicked element + open the shell's
1223
+ // right Inspector panel (Inspect / Layers / CSS). Was a disabled TODO
1224
+ // before the inspector shipped.
1225
+ if (target.el) {
1226
+ selSet.replace(
1227
+ hoverTargetToSelection({
1228
+ el: target.el,
1229
+ cdId: target.cdId ?? null,
1230
+ artboardId: target.artboardId ?? null,
1231
+ } as HoverTarget)
1232
+ );
1233
+ }
1234
+ try {
1235
+ window.parent.postMessage({ dgn: 'open-inspector' }, '*');
1236
+ } catch {
1237
+ /* detached / cross-origin */
1238
+ }
1115
1239
  },
1116
1240
  },
1117
1241
  ],
@@ -1248,6 +1372,13 @@ function CanvasRouter({
1248
1372
  const annotSel = useAnnotationSelection();
1249
1373
  const ctxMenu = useContextMenu();
1250
1374
  const undoStack = useUndoStack();
1375
+ // Shell View-menu zoom bridge (dgn:'zoom') — same controller the zoom pill uses.
1376
+ const zoomController = useViewportControllerContext();
1377
+ // Shell View-menu chrome-visibility bridge (dgn:'view-chrome') — minimap /
1378
+ // zoom-controls toggles + Presentation Mode. Drives the shared chrome store
1379
+ // the floating chrome (DCMiniMap, DCZoomToolbar, ToolPalette, AnnotationsLayer)
1380
+ // consumes.
1381
+ const chromeCtx = useChromeVisibility();
1251
1382
 
1252
1383
  // Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
1253
1384
  // itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
@@ -1312,6 +1443,87 @@ function CanvasRouter({
1312
1443
  void undoStack.redo();
1313
1444
  return;
1314
1445
  }
1446
+ // Phase 12 Task 4 (DDR-103) — Layers-tree round-trip. select-by-id resolves
1447
+ // the node + replaces the selection (which posts select-set back, updating
1448
+ // the Inspect/CSS tabs + halos); highlight shows the transient hover halo;
1449
+ // request-layers re-walks + posts the tree for an artboard.
1450
+ if (m.dgn === 'select-by-id') {
1451
+ const mm = m as { id?: string; artboardId?: string | null; index?: number };
1452
+ if (mm.id) {
1453
+ const target = resolveSelectionEl(document, {
1454
+ id: mm.id,
1455
+ artboardId: mm.artboardId,
1456
+ index: mm.index,
1457
+ });
1458
+ if (target) {
1459
+ selSet.replace(
1460
+ hoverTargetToSelection({
1461
+ el: target,
1462
+ cdId: mm.id,
1463
+ artboardId: mm.artboardId ?? null,
1464
+ } as HoverTarget)
1465
+ );
1466
+ (target as HTMLElement).scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
1467
+ }
1468
+ }
1469
+ return;
1470
+ }
1471
+ if (m.dgn === 'highlight') {
1472
+ const mm = m as { id?: string; artboardId?: string | null; index?: number };
1473
+ setHoverEl(
1474
+ mm.id
1475
+ ? resolveSelectionEl(document, {
1476
+ id: mm.id,
1477
+ artboardId: mm.artboardId,
1478
+ index: mm.index,
1479
+ })
1480
+ : null
1481
+ );
1482
+ return;
1483
+ }
1484
+ // Phase 12.3 (W1.1) — optimistic inline-style apply. The CSS panel posts
1485
+ // this on commit so the selected element updates INSTANTLY, before the
1486
+ // edit-css → file-watcher HMR reload re-renders from source. `value` null/
1487
+ // empty removes the property (the reset path). Purely a live-DOM preview;
1488
+ // the authoritative value is whatever the reload renders from source.
1489
+ // SECURITY (ethical-hacker A1/A2) — accept apply-style ONLY from the parent
1490
+ // shell, never from a canvas self-post. The canvas iframe is untrusted
1491
+ // (DDR-054); without this gate hostile canvas JS could repaint elements AND
1492
+ // (paired with the reload echo-guard) suppress reloads to desync view/source.
1493
+ // The reload-suppression timestamp now lives in _shell.html's closure (NOT a
1494
+ // canvas-writable window global) — set there from the same parent-gated msg.
1495
+ if (m.dgn === 'apply-style') {
1496
+ if (e.source !== window.parent) return;
1497
+ const mm = m as {
1498
+ id?: string;
1499
+ artboardId?: string | null;
1500
+ index?: number;
1501
+ prop?: string;
1502
+ value?: string | null;
1503
+ };
1504
+ if (mm.id && mm.prop) {
1505
+ const target = resolveSelectionEl(document, {
1506
+ id: mm.id,
1507
+ artboardId: mm.artboardId,
1508
+ index: mm.index,
1509
+ });
1510
+ if (target) {
1511
+ const style = (target as HTMLElement).style;
1512
+ try {
1513
+ // Live-DOM preview only; the reload renders the authoritative value.
1514
+ if (mm.value == null || mm.value === '') style.removeProperty(mm.prop);
1515
+ else style.setProperty(mm.prop, mm.value);
1516
+ } catch {
1517
+ /* invalid prop/value — ignore; the reload is the source of truth */
1518
+ }
1519
+ }
1520
+ }
1521
+ return;
1522
+ }
1523
+ if (m.dgn === 'request-layers') {
1524
+ postLayersTree((m as { artboardId?: string }).artboardId ?? null);
1525
+ return;
1526
+ }
1315
1527
  // D9 — canvas-shell chrome follows the Maude chrome theme. The chrome's
1316
1528
  // `--maude-chrome-*` token family is keyed by `data-maude-theme` on the
1317
1529
  // iframe documentElement (see HUD_TOKENS_CSS). This attribute is
@@ -1325,10 +1537,113 @@ function CanvasRouter({
1325
1537
  }
1326
1538
  return;
1327
1539
  }
1540
+ // Shell View-menu zoom items — route to the live viewport controller
1541
+ // (the same methods the in-canvas zoom pill calls).
1542
+ if (m.dgn === 'zoom' && zoomController) {
1543
+ const op = (m as { op?: string }).op;
1544
+ if (op === 'in') zoomController.zoomIn();
1545
+ else if (op === 'out') zoomController.zoomOut();
1546
+ else if (op === 'fit') zoomController.fit();
1547
+ else if (op === 'actual') zoomController.reset();
1548
+ return;
1549
+ }
1550
+ // Shell View-menu chrome toggles + Presentation Mode. Only the fields the
1551
+ // shell sends are merged, so a `{ present: true }` enter keeps the user's
1552
+ // individual minimap/zoom toggles for the eventual exit.
1553
+ if (m.dgn === 'view-chrome' && chromeCtx) {
1554
+ const mm = m as { minimap?: boolean; zoom?: boolean; present?: boolean };
1555
+ const patch: { minimap?: boolean; zoom?: boolean; present?: boolean } = {};
1556
+ if (typeof mm.minimap === 'boolean') patch.minimap = mm.minimap;
1557
+ if (typeof mm.zoom === 'boolean') patch.zoom = mm.zoom;
1558
+ if (typeof mm.present === 'boolean') patch.present = mm.present;
1559
+ chromeCtx.setChrome(patch);
1560
+ return;
1561
+ }
1328
1562
  };
1329
1563
  window.addEventListener('message', onMessage);
1330
1564
  return () => window.removeEventListener('message', onMessage);
1331
- }, [selSet, annotSel, setTool, undoStack]);
1565
+ }, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx]);
1566
+
1567
+ // Phase 12 (DDR-103) — double-click a LEAF-TEXT element (children all text
1568
+ // nodes) to edit its copy in place. Commit (blur / Enter) posts `dgn:edit-text`
1569
+ // to the shell, which calls the main-origin /_api/edit-text → editText writes
1570
+ // the escaped JSXText to source; the file-watcher HMR reload re-renders from
1571
+ // the persisted source. Esc restores the original. Mixed/expression elements
1572
+ // are skipped (the engine would refuse them anyway — honest no-op).
1573
+ useEffect(() => {
1574
+ if (typeof document === 'undefined') return;
1575
+ const host = hostRef.current;
1576
+ if (!host) return;
1577
+ let editing: HTMLElement | null = null;
1578
+ let original = '';
1579
+ const onBlur = (): void => commitEdit(true);
1580
+ const onKey = (e: KeyboardEvent): void => {
1581
+ if (e.key === 'Escape') {
1582
+ e.preventDefault();
1583
+ commitEdit(false);
1584
+ } else if (e.key === 'Enter' && !e.shiftKey) {
1585
+ e.preventDefault();
1586
+ (e.currentTarget as HTMLElement | null)?.blur?.();
1587
+ }
1588
+ };
1589
+ function teardown(elx: HTMLElement): void {
1590
+ elx.removeAttribute('contenteditable');
1591
+ elx.classList.remove('dc-text-editing');
1592
+ elx.removeEventListener('blur', onBlur, true);
1593
+ elx.removeEventListener('keydown', onKey, true);
1594
+ }
1595
+ function commitEdit(commit: boolean): void {
1596
+ const elx = editing;
1597
+ if (!elx) return;
1598
+ editing = null;
1599
+ const text = (elx.textContent ?? '').trim();
1600
+ teardown(elx);
1601
+ if (!commit) {
1602
+ elx.textContent = original;
1603
+ return;
1604
+ }
1605
+ if (text === original.trim()) return;
1606
+ const cdId = elx.getAttribute('data-cd-id');
1607
+ if (!cdId) return;
1608
+ try {
1609
+ window.parent.postMessage({ dgn: 'edit-text', id: cdId, file: deriveFile(), text }, '*');
1610
+ } catch {
1611
+ /* detached / cross-origin */
1612
+ }
1613
+ }
1614
+ const onDbl = (e: MouseEvent): void => {
1615
+ if (editing) return;
1616
+ const t = e.target as HTMLElement | null;
1617
+ const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
1618
+ if (!stamped) return;
1619
+ const kids = Array.from(stamped.childNodes);
1620
+ if (kids.length === 0 || !kids.every((n) => n.nodeType === 3)) return; // leaf-text only
1621
+ e.preventDefault();
1622
+ e.stopPropagation();
1623
+ editing = stamped;
1624
+ original = stamped.textContent ?? '';
1625
+ stamped.setAttribute('contenteditable', 'plaintext-only');
1626
+ stamped.classList.add('dc-text-editing');
1627
+ stamped.addEventListener('blur', onBlur, true);
1628
+ stamped.addEventListener('keydown', onKey, true);
1629
+ stamped.focus();
1630
+ try {
1631
+ const range = document.createRange();
1632
+ range.selectNodeContents(stamped);
1633
+ const sel = window.getSelection();
1634
+ sel?.removeAllRanges();
1635
+ sel?.addRange(range);
1636
+ } catch {
1637
+ /* selection API unavailable */
1638
+ }
1639
+ };
1640
+ // Capture phase so we beat the fit-to-view dblclick handler.
1641
+ host.addEventListener('dblclick', onDbl, true);
1642
+ return () => {
1643
+ host.removeEventListener('dblclick', onDbl, true);
1644
+ if (editing) teardown(editing);
1645
+ };
1646
+ }, [hostRef]);
1332
1647
 
1333
1648
  // Cleanup any pending rAF on unmount.
1334
1649
  useEffect(
@@ -1363,6 +1678,8 @@ function CanvasRouter({
1363
1678
  const sel = hoverTargetToSelection(target);
1364
1679
  if (mode === 'replace') selSet.replace(sel);
1365
1680
  else selSet.add(sel);
1681
+ // Phase 12 Task 4 — refresh the Layers tree for the selected artboard.
1682
+ postLayersTree(target.artboardId);
1366
1683
  },
1367
1684
  onContextMenu: ({ clientX, clientY }) => {
1368
1685
  const target = resolveHoverTarget(document, clientX, clientY, { deep: true });
@@ -1754,7 +2071,7 @@ function HoverHalo({ el }: { el: Element | null }) {
1754
2071
  rafRef.current = null;
1755
2072
  const div = ref.current;
1756
2073
  const t = targetRef.current;
1757
- if (!div || !t || !t.isConnected) {
2074
+ if (!div || !t?.isConnected) {
1758
2075
  if (div) div.style.display = 'none';
1759
2076
  return;
1760
2077
  }