@1agh/maude 0.23.0 → 0.25.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 (127) 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 +32 -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 +126 -2
  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 +74 -10
  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/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -98,25 +98,63 @@ import { useUndoStack } from './use-undo-stack.tsx';
98
98
  // plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
99
99
  // = invisible). No per-element class stamping is used.
100
100
 
101
- // 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
102
102
  // dev-server chrome (toolbar + minimap + halos + marquee + AI banner) used
103
103
  // `var(--accent, …)` which inherited the canvas DS palette — a violet StudyFi
104
104
  // canvas turned the floating cursor toolbar violet. The HUD owns its own
105
105
  // `--maude-hud-*` token family, set on `:root` of the canvas iframe document
106
- // here. Canvas DSs do NOT define `--maude-hud-*`, so HUD CSS resolves against
107
- // 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.
108
109
  //
109
- // Defaults match the existing inline fallback color (`#d63b1f`, Maude brand
110
- // orange-rust) so no visual change to the default theme. Users who want to
111
- // re-theme the HUD can set `--maude-hud-accent` etc. via a `<style>` block
112
- // 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.
113
132
  const HUD_TOKENS_CSS = `
114
- :root {
133
+ :root,
134
+ :root[data-maude-theme="dark"] {
115
135
  --maude-hud-accent: #d63b1f;
116
136
  --maude-hud-accent-hover: #b8331b;
117
137
  --maude-hud-accent-active: #962a16;
118
138
  --maude-hud-accent-fg: #ffffff;
119
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);
120
158
  }
121
159
  `;
122
160
 
@@ -138,7 +176,7 @@ const HALO_CSS = `
138
176
  elements. NO ring, NO ticks. Synchronous paint (no debounce). */
139
177
  .dc-cv-halo--hover {
140
178
  border: 1.5px solid color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 60%, transparent);
141
- 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);
142
180
  }
143
181
  /* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
144
182
  Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
@@ -152,7 +190,7 @@ const HALO_CSS = `
152
190
  height: 8px;
153
191
  background: var(--maude-hud-accent, #0d99ff);
154
192
  border-radius: 1px;
155
- box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
193
+ box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
156
194
  }
157
195
  .dc-cv-halo--selected .tick-tl { top: -3px; left: -3px; }
158
196
  .dc-cv-halo--selected .tick-tr { top: -3px; right: -3px; }
@@ -186,7 +224,7 @@ const HALO_CSS = `
186
224
  height: 6px;
187
225
  background: var(--maude-hud-accent, #0d99ff);
188
226
  border-radius: 1px;
189
- box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
227
+ box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
190
228
  }
191
229
  .dc-cv-group-bbox .tick-tl { top: -3px; left: -3px; }
192
230
  .dc-cv-group-bbox .tick-tr { top: -3px; right: -3px; }
@@ -215,53 +253,19 @@ const HALO_CSS = `
215
253
  transition: none !important;
216
254
  }
217
255
  }
218
- /*
219
- * Force tool cursor across the canvas tree in comment / hand modes. Without
220
- * !important on every descendant, buttons and links with their own cursor
221
- * declaration would flip the cursor away from the tool affordance the moment
222
- * the user hovers an interactive element — wrong signal when native
223
- * interactions are suppressed by the router anyway.
224
- */
225
- /* T22 — per-tool SVG cursors. Each cursor is a 16 × 16 SVG data-URI with a
226
- declared hotspot. The crosshair / cell / grab fallbacks remain in the
227
- chain so older browsers still get a recognisable affordance. SVGs are
228
- utf-8 encoded inline (Chromium / Safari 16+ / Firefox 117+ all accept). */
229
- .dc-canvas {
230
- --cursor-pen: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M14 1.5 L9 6.5 L2.5 13.5 L1.5 14.5 L2 13 L8.5 6 L13.5 1.5 Z' fill='%23111' stroke='white' stroke-width='0.7' stroke-linejoin='round'/></svg>") 2 14;
231
- --cursor-rect: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><rect x='10.5' y='10.5' width='4' height='3' fill='white' stroke='%23111' stroke-width='1'/></svg>") 8 8;
232
- --cursor-ellipse: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><ellipse cx='12.5' cy='12' rx='2.5' ry='1.5' fill='white' stroke='%23111' stroke-width='1'/></svg>") 8 8;
233
- --cursor-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><path d='M10 13 L15 13 M12.5 11 L15 13 L12.5 15' fill='none' stroke='%23111' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/></svg>") 8 8;
234
- --cursor-eraser: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><rect x='3' y='9' width='8' height='5' rx='1' fill='%23ffb3da' stroke='%23111' stroke-width='1' transform='rotate(-20 7 11.5)'/><rect x='3' y='9' width='8' height='2' fill='%23d63b6e' transform='rotate(-20 7 10)' opacity='0.6'/></svg>") 8 14;
235
- --cursor-comment: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M3 3 L14 3 L14 11 L8 11 L4 14 L4 11 L3 11 Z' fill='white' stroke='%23111' stroke-width='1' stroke-linejoin='round'/></svg>") 4 4;
236
- }
237
- .dc-canvas[data-active-tool="comment"],
238
- .dc-canvas[data-active-tool="comment"] * {
239
- cursor: var(--cursor-comment), crosshair !important;
240
- }
241
- .dc-canvas[data-active-tool="pen"],
242
- .dc-canvas[data-active-tool="pen"] * {
243
- cursor: var(--cursor-pen), crosshair !important;
244
- }
245
- .dc-canvas[data-active-tool="rect"],
246
- .dc-canvas[data-active-tool="rect"] * {
247
- cursor: var(--cursor-rect), crosshair !important;
248
- }
249
- .dc-canvas[data-active-tool="ellipse"],
250
- .dc-canvas[data-active-tool="ellipse"] * {
251
- cursor: var(--cursor-ellipse), crosshair !important;
252
- }
253
- .dc-canvas[data-active-tool="arrow"],
254
- .dc-canvas[data-active-tool="arrow"] * {
255
- cursor: var(--cursor-arrow), crosshair !important;
256
- }
257
- .dc-canvas[data-active-tool="hand"],
258
- .dc-canvas[data-active-tool="hand"] * {
259
- cursor: grab !important;
260
- }
261
- .dc-canvas[data-active-tool="eraser"],
262
- .dc-canvas[data-active-tool="eraser"] * {
263
- cursor: var(--cursor-eraser), cell !important;
264
- }
256
+ /* Phase 24 — per-tool cursors are owned SOLELY by use-tool-mode.tsx, which
257
+ injects ONE unified Kenney-glyph rule ('* { cursor: <tool> !important }')
258
+ into the same document. The old T22 per-tool '.dc-canvas[data-active-tool=…]'
259
+ rules used to live here, but their higher specificity ('.class[attr] *'
260
+ beats '*') silently SHADOWED the new cursors for comment/pen/arrow/hand/
261
+ eraser they kept showing the stale 16px glyphs (or their native fallback)
262
+ while move/shape/sticky/text (which had no shadowing rule) correctly showed
263
+ the Kenney set. Removing them lets the single source of truth win for every
264
+ tool. 'data-active-tool' is still set on '.dc-canvas' (see CanvasCore) and
265
+ keyed by canvas-lib's move-mode label cursor + use-cursor-modifiers those
266
+ stay. See DDR-067 / Phase 24. NOTE: keep this comment backtick-free — it
267
+ lives inside the HALO_CSS template literal and a stray backtick closes it
268
+ (bun parse fail, DDR-067 §6). */
265
269
 
266
270
  /* T31 — Level of detail. Below 0.35 zoom we hide pre-attentive chrome that
267
271
  becomes visual noise (corner ticks, distance pills, active-artboard ring,
@@ -313,6 +317,39 @@ export function CanvasShell({
313
317
  children: ReactNode;
314
318
  }) {
315
319
  ensureHaloStyles();
320
+
321
+ // theme-default-follow — every un-pinned artboard tracks the chrome theme.
322
+ // When it flips (the theme postMessage handler in CanvasRouter updates
323
+ // `data-maude-theme` on <html>), re-theme them. A single observer per canvas
324
+ // (cleaned up on unmount) — no per-artboard listeners to leak. The rule is a
325
+ // CSS block keyed by data-dc-screen (see rebuildArtboardThemeStyle), so it
326
+ // survives React re-renders without flicker.
327
+ useEffect(() => {
328
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return;
329
+ const obs = new MutationObserver(() => applyArtboardFollowers());
330
+ obs.observe(document.documentElement, {
331
+ attributes: true,
332
+ attributeFilter: ['data-maude-theme'],
333
+ });
334
+ // Initial follow — theme every un-pinned artboard once the DS stylesheet has
335
+ // parsed. The observer only fires on a `data-maude-theme` CHANGE, which may
336
+ // not happen on first paint (the chrome theme can already equal the canvas
337
+ // default), so first paint needs its own trigger. `window 'load'` guarantees
338
+ // every <link>/<style> sheet is parsed → collectThemeDeclarations sees the
339
+ // DS tokens. Runs immediately if the document is already complete.
340
+ let onLoad: (() => void) | null = null;
341
+ if (document.readyState === 'complete') {
342
+ applyArtboardFollowers();
343
+ } else {
344
+ onLoad = () => applyArtboardFollowers();
345
+ window.addEventListener('load', onLoad, { once: true });
346
+ }
347
+ return () => {
348
+ obs.disconnect();
349
+ if (onLoad) window.removeEventListener('load', onLoad);
350
+ };
351
+ }, []);
352
+
316
353
  // ToolProvider is mounted by DesignCanvas one level up (so the viewport
317
354
  // controller's `isPanDragActive` predicate can read the live tool state).
318
355
  // SelectionSetProvider is mounted via MaybeSelectionSetProvider — the shell-
@@ -659,6 +696,235 @@ function CanvasCore({
659
696
  );
660
697
  }
661
698
 
699
+ // ─────────────────────────────────────────────────────────────────────────────
700
+ // Artboard theme (D9 + theme-default-follow, 2026-05-31). The status-bar theme
701
+ // toggle (vpravo dole) is the SINGLE SOURCE OF TRUTH: the canvas-shell chrome
702
+ // AND every artboard follow it by default via `data-maude-theme`. The right-
703
+ // click `Theme ▸ DS default / Light / Dark / Follow chrome` submenu PINS one
704
+ // artboard to override that default — re-themed by stamping its content wrapper
705
+ // with the DS's theme-wrapper convention. (Before this date the default was the
706
+ // inverse — artboards kept their own DS theme and following was opt-in — which
707
+ // produced the recurring "canvas dark / chrome light" mismatch.)
708
+ //
709
+ // DS theme-wrapper conventions vary (`.mdcc[data-theme]`, `.app[data-theme]`,
710
+ // bare `[data-theme]`, …) and there's no reliable config flag, so we DETECT it
711
+ // with a hidden computed-style probe: stamp a throwaway nested <div> with each
712
+ // candidate `<class>[data-theme=light|dark]` and keep the first whose resolved
713
+ // `--bg-0` differs between light and dark. Because the probe tests a NON-root
714
+ // element, "supported" is exactly "stamping one artboard will work" — a DS that
715
+ // only themes `:root[data-theme]` correctly reports unsupported (you genuinely
716
+ // can't theme a single artboard there).
717
+
718
+ interface DsThemeSupport {
719
+ supported: boolean;
720
+ /** The class the DS scopes its theme blocks to (`''` = bare `[data-theme]`). */
721
+ wrapperClass: string;
722
+ }
723
+
724
+ let _dsThemeSupport: DsThemeSupport | null = null;
725
+
726
+ function detectDsThemeSupport(): DsThemeSupport {
727
+ if (_dsThemeSupport) return _dsThemeSupport;
728
+ const fallback: DsThemeSupport = { supported: false, wrapperClass: '' };
729
+ if (typeof document === 'undefined' || !document.body) return fallback;
730
+ try {
731
+ const host = document.createElement('div');
732
+ host.setAttribute('aria-hidden', 'true');
733
+ host.style.cssText =
734
+ 'position:absolute;left:-9999px;top:0;width:0;height:0;overflow:hidden;pointer-events:none;';
735
+ document.body.appendChild(host);
736
+ const read = (cls: string, theme: string): string => {
737
+ const el = document.createElement('div');
738
+ if (cls) el.className = cls;
739
+ el.setAttribute('data-theme', theme);
740
+ host.appendChild(el);
741
+ const v = getComputedStyle(el).getPropertyValue('--bg-0').trim();
742
+ host.removeChild(el);
743
+ return v;
744
+ };
745
+ // Bare attribute first (cleanest), then the common class conventions.
746
+ let found = fallback;
747
+ for (const cls of ['', 'mdcc', 'app']) {
748
+ const light = read(cls, 'light');
749
+ const dark = read(cls, 'dark');
750
+ if (light && dark && light !== dark) {
751
+ found = { supported: true, wrapperClass: cls };
752
+ break;
753
+ }
754
+ }
755
+ document.body.removeChild(host);
756
+ // Cache only a POSITIVE detection. With follow-chrome now the artboard
757
+ // default, this can run on first paint — before the DS stylesheet has
758
+ // parsed — where the probe reads empty tokens and wrongly reports
759
+ // unsupported. Caching that would permanently disable theming; instead
760
+ // re-probe until support is confirmed (or the DS genuinely has one theme).
761
+ if (found.supported) _dsThemeSupport = found;
762
+ return found;
763
+ } catch {
764
+ return fallback;
765
+ }
766
+ }
767
+
768
+ // Why not just stamp `data-theme` on the `.dc-artboard` element? Two reasons,
769
+ // both found the hard way: (1) React OWNS the artboard <article> + the canvas
770
+ // content's `rootClass` wrapper — it reconciles their className/attrs back to
771
+ // the JSX values on every re-render, wiping any imperative mutation; and (2)
772
+ // the canvas content carries its OWN `rootClass[data-theme]` wrapper (the DS
773
+ // default), which re-establishes the default tokens BELOW the artboard, so
774
+ // setting `data-theme` on the outer article never reaches the content. The
775
+ // robust mechanism is a single injected <style> keyed by the STABLE
776
+ // `data-dc-screen` attribute (which React always re-renders WITH), re-declaring
777
+ // the chosen theme's `--*` tokens scoped to the artboard's content wrapper.
778
+ // Survives re-renders, beats the wrapper on cascade order, zero flicker.
779
+
780
+ // Reject token VALUES that could turn the re-emitted <style> into a resource
781
+ // fetch / exfil beacon. Per the CSS spec a custom-property value can't contain
782
+ // an unmatched top-level `}` (so brace-breakout to other selectors is already
783
+ // impossible), but `url()` / `image()` / `@import` / comments survive verbatim.
784
+ // For a TRUSTED same-origin DS this is inert, but a DDR-054 untrusted synced
785
+ // canvas could ship `--bg-0: #fff url(https://attacker/x?leak)` — copying it
786
+ // here would fire that fetch when the artboard renders. Drop such values; the
787
+ // token simply falls back to the DS default for that artboard. (Security
788
+ // review F2, 2026-05-29 — defense-in-depth on top of the canvas-origin CSP.)
789
+ const _UNSAFE_TOKEN_VALUE = /url\(|image\(|image-set\(|-image-set\(|@import|\/\*|expression\(/i;
790
+
791
+ // Collect a DS theme block's custom-property declarations by scanning the
792
+ // loaded stylesheets for top-level rules whose selector targets that theme.
793
+ // Only `--*` props are copied (token re-definitions) — never layout/type, and
794
+ // never @media-nested rules (e.g. the prefers-reduced-motion 1ms collapse).
795
+ const _themeDecls: Partial<Record<'light' | 'dark', string>> = {};
796
+ function collectThemeDeclarations(theme: 'light' | 'dark'): string {
797
+ const cached = _themeDecls[theme];
798
+ if (cached != null) return cached;
799
+ let decls = '';
800
+ if (typeof document !== 'undefined') {
801
+ for (const sheet of Array.from(document.styleSheets)) {
802
+ let rules: CSSRuleList | undefined;
803
+ try {
804
+ rules = sheet.cssRules;
805
+ } catch {
806
+ continue; // cross-origin / unreadable sheet
807
+ }
808
+ if (!rules) continue;
809
+ for (const rule of Array.from(rules)) {
810
+ if (!(rule instanceof CSSStyleRule)) continue;
811
+ if (!(rule.selectorText || '').includes(`[data-theme="${theme}"]`)) continue;
812
+ const style = rule.style;
813
+ for (let i = 0; i < style.length; i++) {
814
+ const prop = style[i];
815
+ if (!prop.startsWith('--')) continue;
816
+ const value = style.getPropertyValue(prop);
817
+ if (_UNSAFE_TOKEN_VALUE.test(value)) continue;
818
+ decls += `${prop}:${value};`;
819
+ }
820
+ }
821
+ }
822
+ }
823
+ // Cache only a NON-EMPTY result — same first-paint race as detectDsThemeSupport:
824
+ // a scan that runs before the DS stylesheet parses finds nothing, and caching
825
+ // '' would freeze the artboard un-themed forever. Leave it uncached so the
826
+ // post-load rebuild (window 'load' / the theme MutationObserver) repopulates it.
827
+ if (decls) _themeDecls[theme] = decls;
828
+ return decls;
829
+ }
830
+
831
+ // Every artboard FOLLOWS the chrome theme by default (the status-bar toggle is
832
+ // the source of truth). A per-artboard pin OVERRIDES that: 'ds' = the design
833
+ // system's native theme (opt out of following entirely), 'light' / 'dark' = a
834
+ // fixed theme. Absence from this map = follow chrome.
835
+ type ArtboardPin = 'ds' | 'light' | 'dark';
836
+ const _artboardPins = new Map<string, ArtboardPin>();
837
+
838
+ function cssEsc(v: string): string {
839
+ return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(v) : v;
840
+ }
841
+
842
+ // Selector targeting the artboard's content wrapper (where the DS theme block
843
+ // is re-established). Beats `.<wrapper>[data-theme="…"]` because the injected
844
+ // <style> is appended after the DS stylesheet (later-wins on equal specificity).
845
+ function artboardScopeSelector(screenId: string): string {
846
+ const { wrapperClass } = detectDsThemeSupport();
847
+ const base = `[data-dc-screen="${cssEsc(screenId)}"]`;
848
+ if (wrapperClass) {
849
+ const c = cssEsc(wrapperClass);
850
+ return `${base} .${c},${base}.${c}`;
851
+ }
852
+ return `${base} [data-theme],${base}[data-theme]`;
853
+ }
854
+
855
+ // Like artboardScopeSelector but matches EVERY artboard except the pinned ones —
856
+ // backs the default-follow rule, which themes all un-pinned artboards in one
857
+ // declaration. Pinned ids are :not()-excluded so the per-screen pin rule never
858
+ // has to out-specify this global one (they target disjoint elements).
859
+ function globalArtboardScopeSelector(excludeIds: string[]): string {
860
+ const { wrapperClass } = detectDsThemeSupport();
861
+ const not = excludeIds.map((id) => `:not([data-dc-screen="${cssEsc(id)}"])`).join('');
862
+ const base = `[data-dc-screen]${not}`;
863
+ if (wrapperClass) {
864
+ const c = cssEsc(wrapperClass);
865
+ return `${base} .${c},${base}.${c}`;
866
+ }
867
+ return `${base} [data-theme],${base}[data-theme]`;
868
+ }
869
+
870
+ function rebuildArtboardThemeStyle(): void {
871
+ if (typeof document === 'undefined') return;
872
+ let el = document.getElementById('dc-artboard-theme-css') as HTMLStyleElement | null;
873
+ if (!el) {
874
+ el = document.createElement('style');
875
+ el.id = 'dc-artboard-theme-css';
876
+ document.head.appendChild(el);
877
+ }
878
+ let css = '';
879
+ // (1) Default — every un-pinned artboard follows the chrome theme. Pinned
880
+ // artboards are :not()-excluded so this rule never competes with their
881
+ // per-screen rule below. When the DS doesn't define the chrome theme's tokens
882
+ // (collectThemeDeclarations → ''), nothing is emitted and the artboard keeps
883
+ // its native DS theme — there's no alternate palette to follow to.
884
+ const pinnedIds = [..._artboardPins.keys()];
885
+ const followDecls = collectThemeDeclarations(currentChromeTheme());
886
+ if (followDecls) css += `${globalArtboardScopeSelector(pinnedIds)}{${followDecls}}\n`;
887
+ // (2) Explicit per-artboard pins. 'ds' emits nothing (the :not() above already
888
+ // released that artboard back to its native DS theme); 'light' / 'dark'
889
+ // re-declare the chosen theme's tokens scoped to that one artboard.
890
+ for (const [screenId, pin] of _artboardPins) {
891
+ if (pin === 'ds') continue;
892
+ const decls = collectThemeDeclarations(pin);
893
+ if (decls) css += `${artboardScopeSelector(screenId)}{${decls}}\n`;
894
+ }
895
+ el.textContent = css;
896
+ }
897
+
898
+ /** Current canvas-shell chrome theme (mirrors `data-maude-theme` on <html>). */
899
+ function currentChromeTheme(): 'light' | 'dark' {
900
+ if (typeof document === 'undefined') return 'dark';
901
+ return document.documentElement.dataset.maudeTheme === 'light' ? 'light' : 'dark';
902
+ }
903
+
904
+ /**
905
+ * Pin a single artboard's theme (keyed by its stable `data-dc-screen` id), or
906
+ * clear the pin so it returns to the default — following the chrome theme.
907
+ * 'follow' → remove the pin (default: track the chrome theme toggle)
908
+ * 'ds' → pin to the design system's native theme (stop following)
909
+ * 'light' / 'dark' → pin to a fixed theme
910
+ */
911
+ function setArtboardTheme(screenId: string, mode: 'follow' | ArtboardPin): void {
912
+ if (mode === 'follow') _artboardPins.delete(screenId);
913
+ else _artboardPins.set(screenId, mode);
914
+ rebuildArtboardThemeStyle();
915
+ }
916
+
917
+ /**
918
+ * Re-theme every following artboard when the chrome theme flips. The
919
+ * default-follow rule in rebuildArtboardThemeStyle reads currentChromeTheme()
920
+ * live, so a plain rebuild re-points every un-pinned artboard at the new theme
921
+ * in one shot. Kept under the original name so the MutationObserver call site
922
+ * and the initial 'load' trigger stay untouched.
923
+ */
924
+ function applyArtboardFollowers(): void {
925
+ rebuildArtboardThemeStyle();
926
+ }
927
+
662
928
  // ─────────────────────────────────────────────────────────────────────────────
663
929
  // Registry builder — closes over controller + clear callback.
664
930
 
@@ -743,6 +1009,57 @@ function buildRegistry(deps: {
743
1009
  },
744
1010
  });
745
1011
 
1012
+ // Per-artboard theme PIN, keyed by the stable `data-dc-screen` id
1013
+ // (target.artboardId) via an injected stylesheet (see setArtboardTheme).
1014
+ // Following the chrome theme is the default; this submenu overrides it for one
1015
+ // artboard. The DS-supports-both probe gates Light / Dark / "Follow chrome"
1016
+ // (all no-ops on a single-theme DS); only "DS default" is always meaningful.
1017
+ const themeSupport = detectDsThemeSupport();
1018
+ const themeHint = 'This design system defines only one theme';
1019
+ const themeItem: MenuItem = {
1020
+ id: 'theme',
1021
+ label: 'Theme',
1022
+ onSelect: () => {
1023
+ /* parent of a submenu — never invoked directly */
1024
+ },
1025
+ submenu: [
1026
+ {
1027
+ id: 'theme-ds-default',
1028
+ label: 'DS default',
1029
+ onSelect: (target) => {
1030
+ if (target.artboardId) setArtboardTheme(target.artboardId, 'ds');
1031
+ },
1032
+ },
1033
+ {
1034
+ id: 'theme-light',
1035
+ label: 'Light',
1036
+ disabled: !themeSupport.supported,
1037
+ disabledHint: themeHint,
1038
+ onSelect: (target) => {
1039
+ if (target.artboardId) setArtboardTheme(target.artboardId, 'light');
1040
+ },
1041
+ },
1042
+ {
1043
+ id: 'theme-dark',
1044
+ label: 'Dark',
1045
+ disabled: !themeSupport.supported,
1046
+ disabledHint: themeHint,
1047
+ onSelect: (target) => {
1048
+ if (target.artboardId) setArtboardTheme(target.artboardId, 'dark');
1049
+ },
1050
+ },
1051
+ {
1052
+ id: 'theme-follow',
1053
+ label: 'Follow chrome (default)',
1054
+ disabled: !themeSupport.supported,
1055
+ disabledHint: themeHint,
1056
+ onSelect: (target) => {
1057
+ if (target.artboardId) setArtboardTheme(target.artboardId, 'follow');
1058
+ },
1059
+ },
1060
+ ],
1061
+ };
1062
+
746
1063
  return {
747
1064
  element: [
748
1065
  [
@@ -868,6 +1185,7 @@ function buildRegistry(deps: {
868
1185
  onSelect: () => distributeArtboards('y'),
869
1186
  },
870
1187
  ],
1188
+ [themeItem],
871
1189
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
872
1190
  ],
873
1191
  world: [
@@ -944,6 +1262,13 @@ function CanvasRouter({
944
1262
  // user closes the composer.
945
1263
  useEffect(() => {
946
1264
  if (typeof window === 'undefined') return;
1265
+ // Default the canvas-shell chrome theme to dark (matches the dev-server's
1266
+ // own default + the bare `:root` --maude-chrome-* set) so a canvas that
1267
+ // never receives a `dgn:'theme'` message still renders coherent-dark and
1268
+ // the follow-chrome observer (Task 5) has a concrete value to mirror.
1269
+ if (!document.documentElement.dataset.maudeTheme) {
1270
+ document.documentElement.dataset.maudeTheme = 'dark';
1271
+ }
947
1272
  const onMessage = (e: MessageEvent) => {
948
1273
  const m = e.data as { dgn?: string } | null;
949
1274
  if (!m || typeof m !== 'object' || !m.dgn) return;
@@ -958,6 +1283,19 @@ function CanvasRouter({
958
1283
  if (typeof t === 'string') setTool(t as never);
959
1284
  return;
960
1285
  }
1286
+ // D9 — canvas-shell chrome follows the Maude chrome theme. The chrome's
1287
+ // `--maude-chrome-*` token family is keyed by `data-maude-theme` on the
1288
+ // iframe documentElement (see HUD_TOKENS_CSS). This attribute is
1289
+ // DELIBERATELY separate from the DS `data-theme`: it only re-themes the
1290
+ // floating chrome, never the artboard palettes. Followers (per-artboard
1291
+ // "Follow chrome") restamp via the MutationObserver in CanvasShell.
1292
+ if (m.dgn === 'theme') {
1293
+ const t = (m as { theme?: string }).theme;
1294
+ if (t === 'light' || t === 'dark') {
1295
+ document.documentElement.dataset.maudeTheme = t;
1296
+ }
1297
+ return;
1298
+ }
961
1299
  };
962
1300
  window.addEventListener('message', onMessage);
963
1301
  return () => window.removeEventListener('message', onMessage);
@@ -1085,14 +1423,14 @@ const MULTI_TOOLBAR_CSS = `
1085
1423
  align-items: stretch;
1086
1424
  gap: 2px;
1087
1425
  padding: 4px;
1088
- background: var(--u-bg-0, var(--bg-0, #ffffff));
1089
- border: 1px solid var(--u-fg-0, #1c1917);
1426
+ background: var(--maude-chrome-bg-0, #ffffff);
1427
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
1090
1428
  border-radius: 8px;
1091
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
1092
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1429
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
1430
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1093
1431
  font-size: 11px;
1094
1432
  letter-spacing: 0.02em;
1095
- color: var(--u-fg-0, #1a1a1a);
1433
+ color: var(--maude-chrome-fg-0, #1a1a1a);
1096
1434
  user-select: none;
1097
1435
  }
1098
1436
  .dc-multi-artboard-tb button {
@@ -1118,8 +1456,8 @@ const MULTI_TOOLBAR_CSS = `
1118
1456
  }
1119
1457
  .dc-multi-artboard-tb .dc-mab-count {
1120
1458
  padding: 4px 8px 4px 10px;
1121
- color: var(--fg-1, rgba(40,30,20,0.7));
1122
- border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
1459
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.7));
1460
+ border-right: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08));
1123
1461
  margin-right: 2px;
1124
1462
  font-variant-numeric: tabular-nums;
1125
1463
  }
@@ -1131,7 +1469,7 @@ const MULTI_TOOLBAR_CSS = `
1131
1469
  .dc-multi-artboard-tb .dc-mab-divider {
1132
1470
  width: 1px;
1133
1471
  align-self: stretch;
1134
- background: var(--u-border-subtle, rgba(0,0,0,0.10));
1472
+ background: var(--maude-chrome-border, rgba(0,0,0,0.10));
1135
1473
  margin: 0 4px;
1136
1474
  }
1137
1475
  @media (prefers-reduced-motion: reduce) {