@1agh/maude 0.22.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,80 @@
1
+ // Sync status store tests — Phase 9 Task 8.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import type { SyncStatusSnapshot } from '../sync/connection-state.ts';
6
+ import { type SyncStatusPayload, createSyncStatusStore } from '../sync/status.ts';
7
+
8
+ function snap(partial: Partial<SyncStatusSnapshot> = {}): SyncStatusSnapshot {
9
+ return {
10
+ state: 'online',
11
+ queuedOps: 0,
12
+ lastSyncAt: null,
13
+ offlineSince: null,
14
+ flash: null,
15
+ updatedAt: 1,
16
+ ...partial,
17
+ };
18
+ }
19
+
20
+ function makeStore() {
21
+ const writes: SyncStatusPayload[] = [];
22
+ const broadcasts: SyncStatusPayload[] = [];
23
+ const store = createSyncStatusStore({
24
+ url: 'https://hub.example.com',
25
+ canvases: 3,
26
+ write: (p) => writes.push(p),
27
+ broadcast: (p) => broadcasts.push(p),
28
+ now: () => 42,
29
+ });
30
+ return { store, writes, broadcasts };
31
+ }
32
+
33
+ describe('sync status store', () => {
34
+ test('update() writes + broadcasts the merged payload', () => {
35
+ const { store, writes, broadcasts } = makeStore();
36
+ store.update(snap({ state: 'offline', queuedOps: 2 }));
37
+ expect(writes).toHaveLength(1);
38
+ expect(broadcasts).toHaveLength(1);
39
+ expect(writes[0].state).toBe('offline');
40
+ expect(writes[0].queuedOps).toBe(2);
41
+ expect(writes[0].url).toBe('https://hub.example.com');
42
+ expect(writes[0].canvases).toBe(3);
43
+ expect(writes[0].conflicts).toEqual([]);
44
+ });
45
+
46
+ test('addConflict() appends a stamped conflict + reflects in get()', () => {
47
+ const { store } = makeStore();
48
+ store.addConflict({ slug: 'screen', kind: 'cold-start-hub-wins' });
49
+ const p = store.get();
50
+ expect(p.conflicts).toHaveLength(1);
51
+ expect(p.conflicts[0]).toEqual({ slug: 'screen', kind: 'cold-start-hub-wins', at: 42 });
52
+ });
53
+
54
+ test('conflict list is capped at maxConflicts (drops oldest)', () => {
55
+ const writes: SyncStatusPayload[] = [];
56
+ const store = createSyncStatusStore({
57
+ url: 'https://h',
58
+ canvases: 1,
59
+ write: (p) => writes.push(p),
60
+ maxConflicts: 2,
61
+ now: () => 1,
62
+ });
63
+ store.addConflict({ slug: 'a', kind: 'git-pull' });
64
+ store.addConflict({ slug: 'b', kind: 'git-pull' });
65
+ store.addConflict({ slug: 'c', kind: 'git-pull' });
66
+ const p = store.get();
67
+ expect(p.conflicts.map((c) => c.slug)).toEqual(['b', 'c']);
68
+ });
69
+
70
+ test('a throwing writer never propagates into the caller', () => {
71
+ const store = createSyncStatusStore({
72
+ url: 'https://h',
73
+ canvases: 1,
74
+ write: () => {
75
+ throw new Error('disk full');
76
+ },
77
+ });
78
+ expect(() => store.update(snap({ state: 'offline' }))).not.toThrow();
79
+ });
80
+ });
@@ -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 →
@@ -111,11 +111,164 @@ export interface CollabAwarenessState {
111
111
 
112
112
  export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
113
113
 
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // Untrusted-input sanitization at the awareness trust boundary.
116
+ //
117
+ // Phase 8 awareness was loopback-only — every state came from a trusted local
118
+ // tab. Phase 9 (Task 5) bridges awareness through a SEMI-TRUSTED hub (DDR-054),
119
+ // so foreign states are now attacker-influenceable. `useForeignAwareness` is
120
+ // the single chokepoint where remote state is read before it reaches the
121
+ // cursor / participant render sinks, so all validation lives here. Fields are
122
+ // validated for VALUE, not just type:
123
+ // - color: re-derived locally from the (sanitized) name and the wire value
124
+ // is DISCARDED — a hub-chosen `color` string would otherwise flow into an
125
+ // inline `style` and a `url(...)` value beacons every viewer's browser.
126
+ // The palette is deterministic, so re-derivation is visually identical.
127
+ // - name: control / bidi / zero-width chars stripped, length-capped — blocks
128
+ // identity spoofing + render bloat.
129
+ // - cursor / viewport: finite-number gated — a NaN/Infinity would poison the
130
+ // CSS transform / the local viewport controller during Follow mode.
131
+ // - selection.cssPath: charset + length allowlist before it reaches
132
+ // `querySelector` — blocks selector-complexity DoS + arbitrary DOM probing.
133
+ // - annotationSelection: per-id token + array-length capped — blocks a
134
+ // querySelector render-storm.
135
+ // - peer count capped — blocks an unbounded-clients memory/render DoS.
136
+
137
+ const MAX_FOREIGN_PEERS = 64;
138
+ const MAX_NAME_LEN = 64;
139
+ const MAX_CSSPATH_LEN = 512;
140
+ const MAX_ANNOTATION_IDS = 256;
141
+ const MAX_ANNOTATION_ID_LEN = 128;
142
+
143
+ // Charset of every selector the canvas-shell `cssPath()` emits
144
+ // (`[data-*="..."]`, `#id`, `tag.cls:nth-child(N)`, ` > ` combinators).
145
+ const CSSPATH_ALLOWED = /^[A-Za-z0-9 ._#>:[\]="'()-]+$/;
146
+ // `cssPath()` only ever emits `:nth-child(N)` as a parenthesised construct.
147
+ // Functional pseudo-classes (`:has()`, `:is()`, `:where()`, `:not()`) trigger
148
+ // per-render subtree walks → a malicious hub peer could publish a deeply
149
+ // nested `:has()` selector and pin every viewer's main thread (querySelector
150
+ // re-runs each render). So after stripping the legit `:nth-child/of-type(N)`
151
+ // forms, any residual paren means a functional pseudo — reject. The charset
152
+ // allowlist alone was wider than the generator (the original DoS hole).
153
+ const CSSPATH_NTH = /:nth-(child|of-type)\(\d{1,4}\)/g;
154
+ const ANNOTATION_ID_ALLOWED = /^[A-Za-z0-9._:-]+$/;
155
+
156
+ function isSafeCssPath(p: string): boolean {
157
+ if (p.length > MAX_CSSPATH_LEN || !CSSPATH_ALLOWED.test(p)) return false;
158
+ const stripped = p.replace(CSSPATH_NTH, '');
159
+ return !stripped.includes('(') && !stripped.includes(')');
160
+ }
161
+
162
+ function isFiniteNum(v: unknown): v is number {
163
+ return typeof v === 'number' && Number.isFinite(v);
164
+ }
165
+
166
+ // Control (C0/C1), zero-width, and bidi-override code points get stripped from
167
+ // displayed strings so a remote peer can't spoof another's identity or hide
168
+ // payloads in labels. A charCode scan (not a regex literal with raw control
169
+ // chars) sidesteps biome's noControlCharactersInRegex while keeping the same
170
+ // semantics — same approach as the hub's sanitizeForLog (DDR-053).
171
+ function isUnsafeCodePoint(cp: number): boolean {
172
+ return (
173
+ cp <= 0x1f ||
174
+ (cp >= 0x7f && cp <= 0x9f) ||
175
+ (cp >= 0x200b && cp <= 0x200f) ||
176
+ (cp >= 0x202a && cp <= 0x202e) ||
177
+ (cp >= 0x2066 && cp <= 0x2069) ||
178
+ cp === 0xfeff
179
+ );
180
+ }
181
+
182
+ function sanitizeName(raw: unknown): string {
183
+ if (typeof raw !== 'string') return 'anonymous';
184
+ let cleaned = '';
185
+ for (const ch of raw) {
186
+ if (!isUnsafeCodePoint(ch.codePointAt(0) ?? 0)) cleaned += ch;
187
+ }
188
+ cleaned = cleaned.trim().slice(0, MAX_NAME_LEN);
189
+ return cleaned || 'anonymous';
190
+ }
191
+
192
+ function sanitizeCursor(raw: unknown): { x: number; y: number } | null {
193
+ if (!raw || typeof raw !== 'object') return null;
194
+ const c = raw as { x?: unknown; y?: unknown };
195
+ return isFiniteNum(c.x) && isFiniteNum(c.y) ? { x: c.x, y: c.y } : null;
196
+ }
197
+
198
+ function sanitizeViewport(raw: unknown): { x: number; y: number; zoom: number } {
199
+ const fallback = { x: 0, y: 0, zoom: 1 };
200
+ if (!raw || typeof raw !== 'object') return fallback;
201
+ const v = raw as { x?: unknown; y?: unknown; zoom?: unknown };
202
+ if (!isFiniteNum(v.x) || !isFiniteNum(v.y) || !isFiniteNum(v.zoom) || v.zoom <= 0)
203
+ return fallback;
204
+ return { x: v.x, y: v.y, zoom: v.zoom };
205
+ }
206
+
207
+ function sanitizeSelection(raw: unknown): CollabAwarenessState['selection'] {
208
+ if (!raw || typeof raw !== 'object') return null;
209
+ const s = raw as { cssPath?: unknown; bounds?: unknown };
210
+ const b = s.bounds as { x?: unknown; y?: unknown; w?: unknown; h?: unknown } | undefined;
211
+ const bounds =
212
+ b && isFiniteNum(b.x) && isFiniteNum(b.y) && isFiniteNum(b.w) && isFiniteNum(b.h)
213
+ ? { x: b.x, y: b.y, w: b.w, h: b.h }
214
+ : null;
215
+ // Only keep cssPath if it matches the locator grammar — otherwise drop it and
216
+ // let the renderer fall back to the (validated) bounds.
217
+ const cssPath = typeof s.cssPath === 'string' && isSafeCssPath(s.cssPath) ? s.cssPath : '';
218
+ if (!cssPath && !bounds) return null;
219
+ return { cssPath, bounds: bounds ?? { x: 0, y: 0, w: 0, h: 0 } };
220
+ }
221
+
222
+ function sanitizeAnnotationSelection(raw: unknown): string[] {
223
+ if (!Array.isArray(raw)) return [];
224
+ const out: string[] = [];
225
+ for (const id of raw) {
226
+ if (out.length >= MAX_ANNOTATION_IDS) break;
227
+ if (
228
+ typeof id === 'string' &&
229
+ id.length <= MAX_ANNOTATION_ID_LEN &&
230
+ ANNOTATION_ID_ALLOWED.test(id)
231
+ )
232
+ out.push(id);
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /**
238
+ * Validate + normalize one foreign awareness state at the trust boundary.
239
+ * Returns null for states that can't be a peer (no usable name). `color` is
240
+ * always re-derived locally from the sanitized name — the wire value is never
241
+ * trusted, which is what closes the hub CSS-`url()` exfil channel. Exported so
242
+ * the hostile-input matrix can exercise it without a React harness.
243
+ */
244
+ export function sanitizeForeignState(clientID: number, state: unknown): ForeignAwareness | null {
245
+ if (!state || typeof state !== 'object') return null;
246
+ const s = state as Partial<CollabAwarenessState>;
247
+ if (typeof s.name !== 'string') return null;
248
+ const name = sanitizeName(s.name);
249
+ return {
250
+ clientID,
251
+ name,
252
+ color: colorForName(name),
253
+ cursor: sanitizeCursor(s.cursor),
254
+ selection: sanitizeSelection(s.selection),
255
+ annotationSelection: sanitizeAnnotationSelection(s.annotationSelection),
256
+ viewport: sanitizeViewport(s.viewport),
257
+ };
258
+ }
259
+
114
260
  // ─────────────────────────────────────────────────────────────────────────────
115
261
  // Context.
116
262
 
117
263
  interface CollabValue {
118
264
  doc: Y.Doc;
265
+ /**
266
+ * SECURITY INVARIANT: in linked mode this Awareness carries states relayed
267
+ * from a SEMI-TRUSTED hub (DDR-054). Foreign states are untrusted input —
268
+ * read them ONLY through `useForeignAwareness`, which sanitizes every field
269
+ * at the trust boundary (`sanitizeForeignState`). Do NOT call
270
+ * `awareness.getStates()` directly in render code; that bypasses the gate.
271
+ */
119
272
  awareness: Awareness;
120
273
  /** Local peer's session-stable color (derived from git user.name). */
121
274
  myColor: string;
@@ -158,19 +311,10 @@ export function useForeignAwareness(): ForeignAwareness[] {
158
311
  const myId = awareness.clientID;
159
312
  for (const [clientID, state] of awareness.getStates() as Map<number, unknown>) {
160
313
  if (clientID === myId) continue;
161
- if (!state || typeof state !== 'object') continue;
162
- const s = state as Partial<CollabAwarenessState>;
163
- // Guard against partial states from stale peers / older protocol versions.
164
- if (typeof s.name !== 'string' || typeof s.color !== 'string') continue;
165
- out.push({
166
- clientID,
167
- name: s.name,
168
- color: s.color,
169
- cursor: s.cursor ?? null,
170
- selection: s.selection ?? null,
171
- annotationSelection: Array.isArray(s.annotationSelection) ? s.annotationSelection : [],
172
- viewport: s.viewport ?? { x: 0, y: 0, zoom: 1 },
173
- });
314
+ if (out.length >= MAX_FOREIGN_PEERS) break; // bound DoS via unbounded peers
315
+ // Sanitize every now-remote field at this trust boundary (Task 5).
316
+ const peer = sanitizeForeignState(clientID, state);
317
+ if (peer) out.push(peer);
174
318
  }
175
319
  return out;
176
320
  }