@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
@@ -0,0 +1,104 @@
1
+ // T4.5 (DDR-054 §3 F3) — untrusted-marker for sync-written files. Verifies the
2
+ // `_untrusted/INDEX.json` marker + the managed `.claudeignore` block: written
3
+ // for the syncable set, cleared when empty, stale entries dropped on re-write,
4
+ // and user-authored `.claudeignore` content outside the block preserved.
5
+
6
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+
12
+ import type { Context } from '../context.ts';
13
+ import type { CanvasDescriptor } from '../sync/index.ts';
14
+ import { clearUntrustedMarkers, writeUntrustedMarkers } from '../sync/untrusted.ts';
15
+
16
+ let repoRoot: string;
17
+ let designRoot: string;
18
+
19
+ beforeEach(() => {
20
+ repoRoot = mkdtempSync(join(tmpdir(), 'untrusted-'));
21
+ designRoot = join(repoRoot, '.design');
22
+ mkdirSync(designRoot, { recursive: true });
23
+ });
24
+ afterEach(() => {
25
+ /* tmpdir — left for the OS to reap */
26
+ });
27
+
28
+ function ctxOf(): Context {
29
+ return { paths: { repoRoot, designRoot } } as unknown as Context;
30
+ }
31
+
32
+ function desc(slug: string, body: string): CanvasDescriptor {
33
+ return {
34
+ slug,
35
+ html: join(designRoot, body),
36
+ comments: join(designRoot, '_comments', `${slug}.json`),
37
+ annotations: join(designRoot, `${slug}.annotations.svg`),
38
+ meta: join(designRoot, body.replace(/\.(tsx|html)$/i, '.meta.json')),
39
+ css: join(designRoot, body.replace(/\.(tsx|html)$/i, '.css')),
40
+ };
41
+ }
42
+
43
+ const HUB = 'https://hub.example.com';
44
+
45
+ describe('writeUntrustedMarkers — T4.5', () => {
46
+ test('writes INDEX.json + a managed .claudeignore block for the synced set', () => {
47
+ const ctx = ctxOf();
48
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
49
+
50
+ const indexAbs = join(designRoot, '_untrusted', 'INDEX.json');
51
+ expect(existsSync(indexAbs)).toBe(true);
52
+ const index = JSON.parse(readFileSync(indexAbs, 'utf8'));
53
+ expect(index.hubUrl).toBe(HUB);
54
+ expect(index.canvases[0].slug).toBe('ui-a');
55
+ expect(index.canvases[0].body).toBe('.design/ui/a.tsx');
56
+ expect(index.canvases[0].meta).toBe('.design/ui/a.meta.json'); // Gap 2: meta is untrusted too
57
+ expect(index.canvases[0].css).toBe('.design/ui/a.css'); // Gap 3: css too
58
+ expect(index.note.toLowerCase()).toContain('untrusted');
59
+
60
+ const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
61
+ expect(ci).toContain('maude:sync-untrusted begin');
62
+ expect(ci).toContain('.design/ui/a.tsx');
63
+ expect(ci).toContain('.design/ui-a.annotations.svg');
64
+ expect(ci).toContain('.design/ui/a.meta.json');
65
+ expect(ci).toContain('.design/ui/a.css');
66
+ expect(ci).toContain('maude:sync-untrusted end');
67
+ });
68
+
69
+ test('re-write with a smaller set drops the stale entry', () => {
70
+ const ctx = ctxOf();
71
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx'), desc('ui-b', 'ui/b.tsx')], HUB);
72
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
73
+ const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
74
+ expect(ci).toContain('.design/ui/a.tsx');
75
+ expect(ci).not.toContain('.design/ui/b.tsx');
76
+ // Only one managed block (no accumulation).
77
+ expect(ci.match(/maude:sync-untrusted begin/g)?.length).toBe(1);
78
+ });
79
+
80
+ test('empty set clears INDEX.json + the .claudeignore block', () => {
81
+ const ctx = ctxOf();
82
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
83
+ writeUntrustedMarkers(ctx, [], HUB);
84
+ expect(existsSync(join(designRoot, '_untrusted', 'INDEX.json'))).toBe(false);
85
+ // .claudeignore had only our block → removed entirely.
86
+ expect(existsSync(join(repoRoot, '.claudeignore'))).toBe(false);
87
+ });
88
+
89
+ test('preserves user-authored .claudeignore content outside the block', () => {
90
+ writeFileSync(join(repoRoot, '.claudeignore'), 'secrets/\n*.key\n');
91
+ const ctx = ctxOf();
92
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
93
+ let ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
94
+ expect(ci).toContain('secrets/');
95
+ expect(ci).toContain('*.key');
96
+ expect(ci).toContain('.design/ui/a.tsx');
97
+ // Clearing leaves the user content intact.
98
+ clearUntrustedMarkers(ctx);
99
+ ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
100
+ expect(ci).toContain('secrets/');
101
+ expect(ci).not.toContain('.design/ui/a.tsx');
102
+ expect(ci).not.toContain('maude:sync-untrusted');
103
+ });
104
+ });
@@ -12,7 +12,7 @@ import {
12
12
  } from '../use-tool-mode.tsx';
13
13
 
14
14
  describe('use-tool-mode / static', () => {
15
- test('DEFAULT_TOOLS exposes V/H/C + Phase 5.1 draw set B/R/O/A/E', () => {
15
+ test('DEFAULT_TOOLS exposes V/H/C + draw set B/R/O/N/A/T/E (Phase 21 sticky+text)', () => {
16
16
  expect(DEFAULT_TOOLS.map((t) => t.id)).toEqual([
17
17
  'move',
18
18
  'hand',
@@ -20,26 +20,54 @@ describe('use-tool-mode / static', () => {
20
20
  'pen',
21
21
  'rect',
22
22
  'ellipse',
23
+ 'sticky',
23
24
  'arrow',
25
+ 'text',
24
26
  'eraser',
25
27
  ]);
26
- expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual(['V', 'H', 'C', 'B', 'R', 'O', 'A', 'E']);
28
+ expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual([
29
+ 'V',
30
+ 'H',
31
+ 'C',
32
+ 'B',
33
+ 'R',
34
+ 'O',
35
+ 'N',
36
+ 'A',
37
+ 'T',
38
+ 'E',
39
+ ]);
27
40
  });
28
41
 
29
42
  test('DEFAULT_TOOLS is immutable (Object.freeze applied)', () => {
30
43
  expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
31
44
  });
32
45
 
33
- test('default cursors per tool', () => {
46
+ test('default cursors per tool (Phase 21 — custom SVG cursors + native fallback)', () => {
34
47
  const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
48
+ // Move keeps the system arrow; every other tool ships a data-URI SVG cursor
49
+ // that falls back to the right native cursor if the image is rejected.
35
50
  expect(byId.move).toBe('default');
36
- expect(byId.hand).toBe('grab');
37
- expect(byId.comment).toBe('crosshair');
38
- expect(byId.pen).toBe('crosshair');
39
- expect(byId.rect).toBe('crosshair');
40
- expect(byId.ellipse).toBe('crosshair');
41
- expect(byId.arrow).toBe('crosshair');
42
- expect(byId.eraser).toBe('cell');
51
+ for (const id of [
52
+ 'hand',
53
+ 'comment',
54
+ 'pen',
55
+ 'rect',
56
+ 'ellipse',
57
+ 'sticky',
58
+ 'arrow',
59
+ 'text',
60
+ 'eraser',
61
+ ]) {
62
+ expect(byId[id]).toContain('url("data:image/svg+xml,');
63
+ expect(byId[id]).toContain('32'); // 32×32 cursor
64
+ }
65
+ // Native fallbacks are preserved after the custom URL.
66
+ expect(byId.hand).toMatch(/, grab$/);
67
+ expect(byId.text).toMatch(/, text$/);
68
+ expect(byId.eraser).toMatch(/, cell$/);
69
+ expect(byId.pen).toMatch(/, crosshair$/);
70
+ expect(byId.rect).toMatch(/, crosshair$/);
43
71
  });
44
72
  });
45
73
 
@@ -35,13 +35,13 @@ const PALETTE_CSS = `
35
35
  transform: translateX(-50%);
36
36
  display: flex;
37
37
  align-items: stretch;
38
- background: var(--u-bg-0, var(--bg-0, #ffffff));
39
- border: 1px solid var(--u-fg-0, #1c1917);
38
+ background: var(--maude-chrome-bg-0, #ffffff);
39
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
40
40
  border-radius: 8px;
41
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
42
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
41
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
42
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
43
43
  font-size: 12px;
44
- color: var(--u-fg-0, var(--fg-0, #1a1a1a));
44
+ color: var(--maude-chrome-fg-0, #1a1a1a);
45
45
  z-index: 6;
46
46
  user-select: none;
47
47
  /* Intentionally NO overflow:hidden — the zoom popover (.dc-tp-popover) is
@@ -56,7 +56,7 @@ const PALETTE_CSS = `
56
56
  }
57
57
  .dc-tool-palette .dc-tp-sep {
58
58
  width: 1px;
59
- background: var(--u-border-subtle, rgba(0,0,0,0.08));
59
+ background: var(--maude-chrome-border, rgba(0,0,0,0.08));
60
60
  margin: 6px 0;
61
61
  }
62
62
  .dc-tool-palette button {
@@ -70,14 +70,14 @@ const PALETTE_CSS = `
70
70
  display: inline-flex;
71
71
  align-items: center;
72
72
  justify-content: center;
73
- color: var(--fg-1, rgba(40,30,20,0.75));
73
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.75));
74
74
  cursor: pointer;
75
75
  position: relative;
76
76
  transition: background-color 80ms linear, color 80ms linear;
77
77
  }
78
78
  .dc-tool-palette button:hover {
79
79
  background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
80
- color: var(--fg-0, #1a1a1a);
80
+ color: var(--maude-chrome-fg-0, #1a1a1a);
81
81
  }
82
82
  .dc-tool-palette button:focus-visible {
83
83
  outline: 2px solid var(--maude-hud-accent, #d63b1f);
@@ -117,7 +117,7 @@ const PALETTE_CSS = `
117
117
  height: 6px;
118
118
  background: var(--maude-hud-accent, #d63b1f);
119
119
  border-radius: 1px;
120
- box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
120
+ box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
121
121
  opacity: 0;
122
122
  transition: opacity 50ms linear 50ms;
123
123
  pointer-events: none;
@@ -132,7 +132,7 @@ const PALETTE_CSS = `
132
132
  min-width: 56px;
133
133
  padding: 0 8px;
134
134
  font-variant-numeric: tabular-nums;
135
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
135
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
136
136
  font-size: 11px;
137
137
  letter-spacing: 0.04em;
138
138
  border-radius: 6px;
@@ -145,16 +145,16 @@ const PALETTE_CSS = `
145
145
  position: absolute;
146
146
  right: 4px;
147
147
  bottom: 44px;
148
- background: var(--u-bg-0, var(--bg-0, #ffffff));
149
- border: 1px solid var(--u-fg-0, #1c1917);
148
+ background: var(--maude-chrome-bg-0, #ffffff);
149
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
150
150
  border-radius: 8px;
151
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
151
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
152
152
  display: flex;
153
153
  flex-direction: column;
154
154
  padding: 4px;
155
155
  min-width: 160px;
156
156
  z-index: 7;
157
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
157
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
158
158
  }
159
159
  .dc-tp-popover button {
160
160
  appearance: none;
@@ -171,7 +171,7 @@ const PALETTE_CSS = `
171
171
  gap: 16px;
172
172
  width: 100%;
173
173
  }
174
- .dc-tp-popover button:hover { background: rgba(0,0,0,0.04); }
174
+ .dc-tp-popover button:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 5%, transparent); }
175
175
  .dc-tp-popover .dc-tp-kbd {
176
176
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
177
177
  font-size: 11px;
@@ -189,7 +189,9 @@ function ensurePaletteStyles(): void {
189
189
  }
190
190
 
191
191
  const NAV_TOOLS = ['move', 'hand', 'comment'] as const;
192
- const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'arrow', 'eraser'] as const;
192
+ // Phase 21 sticky clusters with the paper primitives (after ellipse); text
193
+ // sits at the constructive end before the destructive eraser, which stays last.
194
+ const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'sticky', 'arrow', 'text', 'eraser'] as const;
193
195
 
194
196
  // ─────────────────────────────────────────────────────────────────────────────
195
197
  // Component
@@ -23,11 +23,11 @@ const HUD_CSS = `
23
23
  z-index: 7;
24
24
  pointer-events: none;
25
25
  padding: 6px 10px;
26
- background: var(--bg-2, rgba(20, 20, 20, 0.85));
27
- color: var(--fg-1, rgba(255, 255, 255, 0.85));
28
- border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
26
+ background: var(--maude-chrome-bg-2, rgba(20, 20, 20, 0.85));
27
+ color: var(--maude-chrome-fg-1, rgba(255, 255, 255, 0.85));
28
+ border: 1px solid var(--maude-chrome-border, rgba(255, 255, 255, 0.08));
29
29
  border-radius: 6px;
30
- font: 11px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
30
+ font: 11px var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
31
31
  letter-spacing: 0.02em;
32
32
  user-select: none;
33
33
  opacity: 0;
@@ -203,14 +203,30 @@ interface StoreHost {
203
203
 
204
204
  /**
205
205
  * Prefer `window.top` so all canvas iframes (children of the dev-server
206
- * shell) read + write the same Map. Falls back to `window` (when top is
207
- * cross-origin — shouldn't happen in our same-origin setup), then to
208
- * `globalThis` (Node / Bun test runtime where window is absent).
206
+ * shell) read + write the same Map. Falls back to `window` when top is
207
+ * cross-origin — which IS the case under the T2 (9.1-A) segregated canvas
208
+ * origin: the canvas iframe and the shell are different origins, so any
209
+ * PROPERTY access on `window.top` throws SecurityError. Then `globalThis`
210
+ * (Node / Bun test runtime where window is absent).
211
+ *
212
+ * Note: merely reading `window.top` (the reference) never throws — only
213
+ * touching a property of a cross-origin window does. So the guard MUST poke a
214
+ * property inside the try, or the cross-origin throw escapes into React render
215
+ * (it did: the UndoStackProvider's useRef initializer crashed the whole canvas
216
+ * mount to 0 children). Under A1 each iframe falls back to its own `window`
217
+ * store — undo works per-session; cross-close/reopen sharing via window.top is
218
+ * intentionally dropped for the origin isolation (see DDR-0xx / phase-9.1).
209
219
  */
210
220
  function getStoreHost(): StoreHost {
211
221
  if (typeof window !== 'undefined') {
212
222
  try {
213
- return (window.top ?? window) as unknown as StoreHost;
223
+ const top = window.top;
224
+ if (top && top !== window) {
225
+ // Poke a property — throws if `top` is a cross-origin window.
226
+ void (top as unknown as StoreHost).__maude_undo_stacks;
227
+ return top as unknown as StoreHost;
228
+ }
229
+ return window as unknown as StoreHost;
214
230
  } catch {
215
231
  return window as unknown as StoreHost;
216
232
  }
@@ -22,6 +22,7 @@ import {
22
22
  type EllipseStroke,
23
23
  type PenStroke,
24
24
  type RectStroke,
25
+ type StickyStroke,
25
26
  type Stroke,
26
27
  type StrokesStoreValue,
27
28
  strokeBBox,
@@ -58,9 +59,17 @@ function ensureResizeStyles(): void {
58
59
 
59
60
  type Corner = 'nw' | 'ne' | 'sw' | 'se' | 'ep1' | 'ep2';
60
61
 
61
- /** Stroke types that expose resize handles in v1. Text inherits anchor bbox. */
62
- function isResizable(s: Stroke): s is RectStroke | EllipseStroke | ArrowStroke | PenStroke {
63
- return s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'arrow' || s.tool === 'pen';
62
+ /** Stroke types that expose resize handles. Text inherits its anchor bbox. */
63
+ function isResizable(
64
+ s: Stroke
65
+ ): s is RectStroke | EllipseStroke | ArrowStroke | PenStroke | StickyStroke {
66
+ return (
67
+ s.tool === 'rect' ||
68
+ s.tool === 'ellipse' ||
69
+ s.tool === 'arrow' ||
70
+ s.tool === 'pen' ||
71
+ s.tool === 'sticky'
72
+ );
64
73
  }
65
74
 
66
75
  /**
@@ -75,7 +84,9 @@ function resizeStroke(
75
84
  wx: number,
76
85
  wy: number
77
86
  ): Partial<Stroke> | null {
78
- if (start.tool === 'rect') {
87
+ if (start.tool === 'rect' || start.tool === 'sticky') {
88
+ // Sticky resizes exactly like a rect — it shares x / y / w / h. Text
89
+ // re-wraps inside the foreignObject automatically (no special handling).
79
90
  const bbox = { x: start.x, y: start.y, w: start.w, h: start.h };
80
91
  const left = corner === 'nw' || corner === 'sw' ? wx : bbox.x;
81
92
  const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
@@ -86,7 +97,7 @@ function resizeStroke(
86
97
  y: Math.min(top, bottom),
87
98
  w: Math.abs(right - left),
88
99
  h: Math.abs(bottom - top),
89
- } as Partial<RectStroke>;
100
+ } as Partial<RectStroke | StickyStroke>;
90
101
  }
91
102
  if (start.tool === 'ellipse') {
92
103
  // Treat the four corners as the bbox of the ellipse. Drag any corner →
@@ -21,6 +21,7 @@ import {
21
21
  useState,
22
22
  } from 'react';
23
23
 
24
+ import { TOOL_CURSORS } from './canvas-cursors.ts';
24
25
  import type { Tool } from './input-router.tsx';
25
26
 
26
27
  // ─────────────────────────────────────────────────────────────────────────────
@@ -35,19 +36,21 @@ export interface ToolDescriptor {
35
36
  cursor: string;
36
37
  }
37
38
 
39
+ // Phase 21 — every tool ships a custom 32×32 SVG cursor (canvas-cursors.ts)
40
+ // with a white outline halo so the glyph reads on any background. The native
41
+ // crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
42
+ // the tool-palette icons. `move` keeps the system arrow on purpose.
38
43
  export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
39
- { id: 'move', label: 'Move', shortcut: 'V', cursor: 'default' },
40
- { id: 'hand', label: 'Hand', shortcut: 'H', cursor: 'grab' },
41
- { id: 'comment', label: 'Comment', shortcut: 'C', cursor: 'crosshair' },
42
- // Phase 5 draw / annotation tools. Cursors stay as `crosshair` for pen /
43
- // rect / arrow (the pen-tip glyph is reserved for the system text caret).
44
- // Eraser uses `cell` as the closest cross-browser substitute for a rubber
45
- // affordance (no native rubber cursor exists).
46
- { id: 'pen', label: 'Pen', shortcut: 'B', cursor: 'crosshair' },
47
- { id: 'rect', label: 'Rect', shortcut: 'R', cursor: 'crosshair' },
48
- { id: 'ellipse', label: 'Ellipse', shortcut: 'O', cursor: 'crosshair' },
49
- { id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: 'crosshair' },
50
- { id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: 'cell' },
44
+ { id: 'move', label: 'Move', shortcut: 'V', cursor: TOOL_CURSORS.move },
45
+ { id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
46
+ { id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
47
+ { id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
48
+ { id: 'rect', label: 'Rect', shortcut: 'R', cursor: TOOL_CURSORS.rect },
49
+ { id: 'ellipse', label: 'Ellipse', shortcut: 'O', cursor: TOOL_CURSORS.ellipse },
50
+ { id: 'sticky', label: 'Sticky', shortcut: 'N', cursor: TOOL_CURSORS.sticky },
51
+ { id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: TOOL_CURSORS.arrow },
52
+ { id: 'text', label: 'Text', shortcut: 'T', cursor: TOOL_CURSORS.text },
53
+ { id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: TOOL_CURSORS.eraser },
51
54
  ]);
52
55
 
53
56
  interface ToolContextValue {
@@ -25,6 +25,16 @@ export type WsData =
25
25
  remote: string;
26
26
  kind: 'collab';
27
27
  slug: string;
28
+ }
29
+ | {
30
+ // T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
31
+ // ONLY `canvas-hmr` broadcasts; never the privileged inspector feed
32
+ // (comments / ai-activity / git-lifecycle / sync:status / selection) and
33
+ // ignores all inbound messages. Hub-pushed canvas code on the canvas
34
+ // origin can open this, but it leaks nothing and mutates nothing.
35
+ id: string;
36
+ remote: string;
37
+ kind: 'canvas-hmr';
28
38
  };
29
39
 
30
40
  /**
@@ -77,6 +87,8 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
77
87
  }
78
88
  }
79
89
 
90
+ // Privileged inspector feed — comments, selection, ai-activity, git-lifecycle,
91
+ // sync:status, fs:*. ONLY the same-origin inspector clients (the shell) get it.
80
92
  function broadcast(payload: unknown) {
81
93
  const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
82
94
  for (const ws of clients) {
@@ -85,6 +97,17 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
85
97
  }
86
98
  }
87
99
 
100
+ // HMR feed — `canvas-hmr` reload signals. Safe to deliver to the segregated
101
+ // canvas origin, so both inspector (shell) AND canvas-hmr (canvas iframe)
102
+ // sockets receive it. T2 (9.1-A).
103
+ function broadcastHmr(payload: unknown) {
104
+ const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
105
+ for (const ws of clients) {
106
+ if (ws.data.kind !== 'inspector' && ws.data.kind !== 'canvas-hmr') continue;
107
+ send(ws, msg);
108
+ }
109
+ }
110
+
88
111
  // Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
89
112
  // state write; fs-watch.ts emits 'fs:*' on every save.
90
113
  ctx.bus.on('selected', (sel) => broadcast({ type: 'selected', selected: sel }));
@@ -106,9 +129,17 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
106
129
  // canvas iframes both subscribe.
107
130
  ctx.bus.on('git-lifecycle', (payload: unknown) => broadcast({ type: 'git-lifecycle', payload }));
108
131
 
132
+ // Phase 9 Task 8 — hub-down offline mode. The linked-mode sync runtime emits
133
+ // 'sync:status' on every connection-state change (online / connecting /
134
+ // offline / offline-long, queued-op count, conflict notices). Browser tabs
135
+ // render the offline/synced/escalation banner from this. Solo mode never
136
+ // emits, so this is a no-op for unlinked projects.
137
+ ctx.bus.on('sync:status', (payload: unknown) => broadcast({ type: 'sync:status', payload }));
138
+
109
139
  // HMR broadcaster — turns fs:any change events into `canvas-hmr` messages.
110
140
  // The iframe-side client (in _shell.html) decides reload strategy from `mode`.
111
- createHmrBroadcaster(ctx, (msg) => broadcast(msg));
141
+ // Uses broadcastHmr so the segregated canvas origin's HMR-only sockets get it.
142
+ createHmrBroadcaster(ctx, (msg) => broadcastHmr(msg));
112
143
 
113
144
  // Bind a connection to its room. Stored per-socket so close() can find the
114
145
  // right room to disconnect from. Multiplexed via ws.data.id.
@@ -138,6 +169,11 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
138
169
  await room.connect(conn);
139
170
  return;
140
171
  }
172
+ if (ws.data.kind === 'canvas-hmr') {
173
+ // HMR-only: join the broadcast set but get NO inspector snapshot.
174
+ clients.add(ws);
175
+ return;
176
+ }
141
177
  clients.add(ws);
142
178
  send(ws, { type: 'snapshot', state: inspect.state });
143
179
  },
@@ -159,6 +195,9 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
159
195
  clients.delete(ws);
160
196
  },
161
197
  async message(ws, raw) {
198
+ // HMR-only canvas-origin socket: never accepts inbound messages (the
199
+ // canvas iframe only listens for canvas-hmr; it never sends).
200
+ if (ws.data.kind === 'canvas-hmr') return;
162
201
  if (ws.data.kind === 'collab') {
163
202
  const binding = collabConns.get(ws.data.id);
164
203
  if (!binding) return;
@@ -28,6 +28,26 @@
28
28
  <meta charset="utf-8" />
29
29
  <meta name="viewport" content="width=device-width,initial-scale=1" />
30
30
  <title>Canvas</title>
31
+ <script>
32
+ // T2 (9.1 / DDR-060 F1 re-audit) — best-effort WebRTC denial in the canvas
33
+ // iframe. CSP `webrtc 'block'` is specified but UNIMPLEMENTED in shipping
34
+ // Chrome/Firefox as of 2026 (Chromium #40188662, Firefox bug 1783489), so
35
+ // it is currently a no-op, and `connect-src 'self'` does NOT cover WebRTC
36
+ // (it is not Fetch). Hub-pushed canvas JS could otherwise exfil via
37
+ // ICE/STUN. Locking these to undefined raises the bar against the naive
38
+ // case before any canvas module loads. NOT bulletproof — a nested
39
+ // same-origin (about:blank) frame can re-acquire them — so WebRTC exfil
40
+ // stays a DOCUMENTED residual of opt-in linked mode (see the linked-mode
41
+ // README banner). `webrtc 'block'` stays in the CSP for when browsers
42
+ // enforce it. The canvas runtime itself uses zero WebRTC.
43
+ for (var __k of ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCDataChannel']) {
44
+ try {
45
+ Object.defineProperty(window, __k, { value: undefined, configurable: false, writable: false });
46
+ } catch (_) {
47
+ /* already locked / not present */
48
+ }
49
+ }
50
+ </script>
31
51
  <style>
32
52
  :root {
33
53
  color-scheme: light dark;
@@ -150,13 +170,28 @@
150
170
  const v = msg.version || Date.now();
151
171
  // Match by exact filename when we have one; otherwise refresh all.
152
172
  const targetFile = (msg.file || '').split('/').pop();
173
+ let matched = false;
153
174
  for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
154
175
  const href = link.getAttribute('href') || '';
155
176
  if (!targetFile || href.includes(targetFile)) {
156
177
  const base = href.split('?')[0];
157
178
  link.setAttribute('href', base + '?v=' + v);
179
+ matched = true;
158
180
  }
159
181
  }
182
+ // A per-canvas `<slug>.css` imported by the TSX is INLINED into the
183
+ // canvas module as a <style> by Bun.build — there is no <link> to
184
+ // swap, so the loop above matches nothing and the edit is silently
185
+ // dropped until a manual hard-reload (RCA hmr-inlined-css-dropped).
186
+ // When nothing matched AND the changed CSS lives in THIS canvas's
187
+ // directory, reload so the rebuilt module re-inlines the fresh CSS.
188
+ // Scoped to same-dir so an unrelated specimen's CSS write doesn't
189
+ // reload every open tab.
190
+ if (!matched && msg.file && canvasRel) {
191
+ const cssDir = msg.file.replace(/[^/]+$/, '');
192
+ const canvasDir = canvasRel.replace(/[^/]+$/, '');
193
+ if (cssDir === canvasDir) location.reload();
194
+ }
160
195
  } else if (msg.mode === 'module' || msg.mode === 'hard') {
161
196
  // Only reload when the change touches *this* canvas (or `_lib`).
162
197
  if (msg.mode === 'hard' || !msg.file || (canvasRel && msg.file === canvasRel)) {
@@ -253,11 +288,24 @@
253
288
  'Canvas module at ' + canvasUrl + ' has no default export (or it is not a component).'
254
289
  );
255
290
  }
291
+ const rootEl = document.getElementById('canvas-root');
256
292
  mountCanvas(Canvas, {
257
- rootEl: document.getElementById('canvas-root'),
293
+ rootEl,
258
294
  file: designRel + '/' + canvasRel,
259
295
  commentsEnabled,
260
296
  });
297
+ // T2 (9.1-A) diagnostic — tell the parent the canvas mounted + how many
298
+ // root children it rendered. Lets the shell (and tooling) confirm a
299
+ // cross-origin canvas actually rendered, which a parent-page screenshot
300
+ // of an OOPIF cannot show. Harmless heartbeat; ignored if unhandled.
301
+ setTimeout(() => {
302
+ try {
303
+ window.parent.postMessage(
304
+ { dgn: 'canvas-mounted', file: designRel + '/' + canvasRel, children: rootEl ? rootEl.children.length : 0 },
305
+ '*'
306
+ );
307
+ } catch (e) { /* parent gone */ }
308
+ }, 800);
261
309
  } catch (err) {
262
310
  showError((err && err.stack) || String(err));
263
311
  }
@@ -29,6 +29,19 @@
29
29
  * server (canvas-pipeline.ts pass 1). Don't author them by hand — they'd be
30
30
  * stripped + replaced. `/design:handoff` strips them again before the registry
31
31
  * sidecar leaves dev (production has no business with dev-time scaffolding).
32
+ *
33
+ * Theme model (two ISOLATED layers — system-review D9):
34
+ * • Canvas-shell CHROME (workspace plane, floating toolbar, minimap, zoom
35
+ * HUD, halos) auto-follows the Maude dev-server theme via `data-maude-theme`
36
+ * on the iframe <html> — the dev-server sets it; never theme the chrome
37
+ * from canvas code.
38
+ * • ARTBOARDS keep THEIR design system's theme. The `data-theme` on the
39
+ * `{{ROOT_CLASS}}` wrapper pins this artboard to a specific DS theme block;
40
+ * leave it at the DS default ({{THEME_DEFAULT}}). Do NOT hardcode a theme
41
+ * the DS doesn't intend — a reviewer flips an individual artboard at will
42
+ * via right-click → Theme ▸ Light / Dark / Follow chrome (enabled when the
43
+ * DS ships both light + dark token blocks). Only pin a non-default theme
44
+ * when the canvas is intentionally single-theme.
32
45
  */
33
46
 
34
47
  import { useState } from "react";