@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,78 @@
1
+ // Gap 1 (file-sync clobber fix) — the syncRoomFrom* no-op guards.
2
+ //
3
+ // createCollab now re-seeds a live room from disk whenever a synced file
4
+ // changes externally (sync-agent hub-push or `design:edit` write), so the
5
+ // room stops clobbering the external change back. That re-seed reuses
6
+ // syncRoomFromComments/syncRoomFromAnnotations, which ALSO run on the room's
7
+ // own persist → file → fs-event path. Without an equality short-circuit that
8
+ // would spin an 800ms persist storm. These tests pin the guard: an identical
9
+ // re-seed emits NO doc update (→ no persist → no loop); a real change emits one.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import { Y_TYPES } from '../collab/persistence.ts';
14
+ import { createRegistry } from '../collab/registry.ts';
15
+ import type { RoomCallbacks } from '../collab/room.ts';
16
+
17
+ function noopCallbacks(): RoomCallbacks {
18
+ return {
19
+ async seed() {},
20
+ async persistJson() {},
21
+ async persistBinary() {},
22
+ };
23
+ }
24
+
25
+ describe('Gap 1 — syncRoomFromComments no-op guard', () => {
26
+ test('re-seeding identical comments emits NO doc update (loop-safe)', () => {
27
+ const r = createRegistry(noopCallbacks());
28
+ const room = r.get('s');
29
+ const list = [{ id: 'c1', text: 'hi' }];
30
+ r.syncRoomFromComments('s', list);
31
+
32
+ let updates = 0;
33
+ room.doc.on('update', () => {
34
+ updates++;
35
+ });
36
+ // Identical content (the room's-own-persist echo, or a redundant hub push)
37
+ // → must not emit, or the reseed↔persist loop never settles.
38
+ r.syncRoomFromComments('s', [{ id: 'c1', text: 'hi' }]);
39
+ expect(updates).toBe(0);
40
+
41
+ // A genuine change still propagates — exactly one update.
42
+ r.syncRoomFromComments('s', [
43
+ { id: 'c1', text: 'hi' },
44
+ { id: 'c2', text: 'yo' },
45
+ ]);
46
+ expect(updates).toBe(1);
47
+ });
48
+
49
+ test('re-seed brings a STALE room up to the on-disk list (the clobber fix)', () => {
50
+ const r = createRegistry(noopCallbacks());
51
+ const room = r.get('s');
52
+ const arr = room.doc.getArray(Y_TYPES.comments);
53
+ expect(arr.length).toBe(0); // room opened empty (the "B shows []" state)
54
+
55
+ // Sync agent wrote a hub-pushed comment to disk → createCollab re-seeds:
56
+ r.syncRoomFromComments('s', [{ id: 'c1', text: 'from peer A' }]);
57
+ expect(arr.toArray()).toEqual([{ id: 'c1', text: 'from peer A' }]);
58
+ });
59
+ });
60
+
61
+ describe('Gap 1 — syncRoomFromAnnotations no-op guard', () => {
62
+ test('re-seeding the identical svg emits NO doc update', () => {
63
+ const r = createRegistry(noopCallbacks());
64
+ const room = r.get('s');
65
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><g/></svg>';
66
+ r.syncRoomFromAnnotations('s', svg);
67
+
68
+ let updates = 0;
69
+ room.doc.on('update', () => {
70
+ updates++;
71
+ });
72
+ r.syncRoomFromAnnotations('s', svg); // identical → no-op
73
+ expect(updates).toBe(0);
74
+
75
+ r.syncRoomFromAnnotations('s', '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
76
+ expect(updates).toBe(1);
77
+ });
78
+ });
@@ -3,9 +3,15 @@
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
5
 
6
+ import { Awareness } from 'y-protocols/awareness';
6
7
  import * as Y from 'yjs';
7
8
 
8
- import { encodeHandshake, encodeSyncUpdate, handleMessage } from '../collab/protocol.ts';
9
+ import {
10
+ encodeAwarenessFrame,
11
+ encodeHandshake,
12
+ encodeSyncUpdate,
13
+ handleMessage,
14
+ } from '../collab/protocol.ts';
9
15
  import { type RoomCallbacks, type RoomConn, createRoom } from '../collab/room.ts';
10
16
 
11
17
  function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
@@ -140,24 +146,29 @@ describe('Room', () => {
140
146
  await room.destroy();
141
147
  });
142
148
 
143
- test('disconnect cleans up awareness states tagged with __connId', async () => {
149
+ test('disconnect removes the awareness states a conn published', async () => {
144
150
  const cb = makeCallbacks();
145
151
  const room = createRoom('aw-cleanup-slug', cb);
146
152
  const A = makeConn('a');
147
153
  await room.connect(A);
148
154
 
149
- // Simulate Alice publishing her awareness state with the matching __connId.
150
- room.awareness.setLocalState({ name: 'Alice', __connId: 'a' });
151
- expect(room.awareness.getStates().size).toBe(1);
155
+ // Alice (conn A) publishes her awareness over the wire — the server learns
156
+ // her clientID belongs to conn 'a' from the update origin (server-authoritative;
157
+ // it does NOT trust a client-supplied __connId). Mirrors the real receive path.
158
+ const clientDoc = new Y.Doc();
159
+ const clientAw = new Awareness(clientDoc);
160
+ clientAw.setLocalState({ name: 'Alice' });
161
+ room.receive(A, encodeAwarenessFrame(clientAw, [clientAw.clientID]));
162
+ expect(room.awareness.getStates().has(clientAw.clientID)).toBe(true);
152
163
 
153
164
  room.disconnect(A);
154
165
  expect(room.size()).toBe(0);
155
- // Awareness state owned by the disconnected conn is gone.
156
- const states = room.awareness.getStates();
157
- for (const s of states.values()) {
158
- expect((s as { __connId?: string }).__connId).not.toBe('a');
159
- }
166
+ // The disconnected conn's awareness state is dropped immediately no waiting
167
+ // out the ~30s awareness timeout (the phantom-accumulation bug).
168
+ expect(room.awareness.getStates().has(clientAw.clientID)).toBe(false);
160
169
 
170
+ clientAw.destroy();
171
+ clientDoc.destroy();
161
172
  await room.destroy();
162
173
  });
163
174
 
@@ -0,0 +1,87 @@
1
+ // comment-mount — shell-owned comment layer. Covers provider dedup (the
2
+ // Maybe* wrappers consume an outer instance instead of double-mounting) and
3
+ // the bundle scope-guard (react/react-dom externalized to the importmap so the
4
+ // comment layer shares the canvas's single React singleton).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { join } from 'node:path';
8
+
9
+ import { createElement } from 'react';
10
+ import { renderToStaticMarkup } from 'react-dom/server';
11
+
12
+ import {
13
+ MaybeSelectionSetProvider,
14
+ SelectionSetProvider,
15
+ useSelectionSetOptional,
16
+ } from '../use-selection-set.tsx';
17
+ import { MaybeToolProvider, ToolProvider, useToolMode } from '../use-tool-mode.tsx';
18
+
19
+ describe('comment-mount / MaybeToolProvider dedup', () => {
20
+ function ToolReader() {
21
+ const { tool } = useToolMode();
22
+ return createElement('span', { 'data-tool': tool });
23
+ }
24
+
25
+ test('consumes an OUTER ToolProvider instead of double-mounting', () => {
26
+ // Outer provider seeds tool='comment'. If MaybeToolProvider mounted a
27
+ // fresh provider, the reader would see the default 'move' instead.
28
+ const html = renderToStaticMarkup(
29
+ createElement(
30
+ ToolProvider,
31
+ { initial: 'comment' },
32
+ createElement(MaybeToolProvider, null, createElement(ToolReader))
33
+ )
34
+ );
35
+ expect(html).toContain('data-tool="comment"');
36
+ });
37
+
38
+ test('mounts its OWN ToolProvider when none exists above', () => {
39
+ const html = renderToStaticMarkup(
40
+ createElement(MaybeToolProvider, null, createElement(ToolReader))
41
+ );
42
+ expect(html).toContain('data-tool="move"');
43
+ });
44
+ });
45
+
46
+ describe('comment-mount / MaybeSelectionSetProvider dedup', () => {
47
+ function SelReader() {
48
+ const ctx = useSelectionSetOptional();
49
+ // Tag identity so we can assert a single context instance is shared.
50
+ return createElement('span', { 'data-has-ctx': ctx ? 'yes' : 'no' });
51
+ }
52
+
53
+ test('provides a SelectionSet when none exists above', () => {
54
+ const html = renderToStaticMarkup(
55
+ createElement(MaybeSelectionSetProvider, null, createElement(SelReader))
56
+ );
57
+ expect(html).toContain('data-has-ctx="yes"');
58
+ });
59
+
60
+ test('does not throw when nested inside an existing SelectionSetProvider', () => {
61
+ expect(() =>
62
+ renderToStaticMarkup(
63
+ createElement(
64
+ SelectionSetProvider,
65
+ { postTarget: null },
66
+ createElement(MaybeSelectionSetProvider, null, createElement(SelReader))
67
+ )
68
+ )
69
+ ).not.toThrow();
70
+ });
71
+ });
72
+
73
+ describe('comment-mount / bundle scope-guard', () => {
74
+ test('dist/comment-mount.js externalizes react + exports mountCanvas', async () => {
75
+ const here = join(import.meta.dir, '..');
76
+ const bundle = Bun.file(join(here, 'dist', 'comment-mount.js'));
77
+ expect(await bundle.exists()).toBe(true);
78
+ const src = await bundle.text();
79
+ // mountCanvas is the public entry the shell calls.
80
+ expect(src.includes('mountCanvas')).toBe(true);
81
+ // React must be a BARE import (resolved by _shell.html's importmap), not
82
+ // inlined — a second React instance would break hooks ("invalid hook
83
+ // call") across the canvas module and the comment layer.
84
+ expect(/import[^;]*["']react-dom\/client["']/.test(src)).toBe(true);
85
+ expect(/import[^;]*["']react["']/.test(src)).toBe(true);
86
+ });
87
+ });
@@ -0,0 +1,46 @@
1
+ // cspForCanvasShell — A6 (DDR-060 F1 re-audit) CSP hardening for the segregated
2
+ // canvas origin. Asserts the exfil-relevant directives are present and that
3
+ // `frame-ancestors` is only emitted when the main origin is known (so the legit
4
+ // cross-origin embed is never accidentally blocked).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { cspForCanvasShell } from '../http.ts';
9
+
10
+ const SHELL = '<!doctype html><html><head><script>const x=1;</script></head><body></body></html>';
11
+
12
+ describe('cspForCanvasShell — A6 hardening', () => {
13
+ test('locks down the dangerous lanes', () => {
14
+ const csp = cspForCanvasShell(SHELL);
15
+ expect(csp).toContain("default-src 'none'");
16
+ expect(csp).toContain("connect-src 'self'");
17
+ expect(csp).toContain("object-src 'none'");
18
+ expect(csp).toContain("base-uri 'none'");
19
+ // No eval / no inline-script escape hatch.
20
+ expect(csp).not.toContain('unsafe-eval');
21
+ expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/);
22
+ });
23
+
24
+ test("adds webrtc 'block' (connect-src does not cover WebRTC exfil)", () => {
25
+ expect(cspForCanvasShell(SHELL)).toContain("webrtc 'block'");
26
+ });
27
+
28
+ test("adds form-action 'none'", () => {
29
+ expect(cspForCanvasShell(SHELL)).toContain("form-action 'none'");
30
+ });
31
+
32
+ test('hashes inline scripts (no unsafe-inline for script-src)', () => {
33
+ const csp = cspForCanvasShell(SHELL);
34
+ expect(csp).toMatch(/script-src 'self' 'sha256-[A-Za-z0-9+/=]+'/);
35
+ });
36
+
37
+ test('frame-ancestors allowlists the main origin when known', () => {
38
+ const csp = cspForCanvasShell(SHELL, 'http://localhost:4399');
39
+ expect(csp).toContain("frame-ancestors 'self' http://localhost:4399");
40
+ });
41
+
42
+ test('frame-ancestors is OMITTED when the main origin is unknown (would block the legit embed)', () => {
43
+ const csp = cspForCanvasShell(SHELL);
44
+ expect(csp).not.toContain('frame-ancestors');
45
+ });
46
+ });
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><path data-id="p1" data-tool="pen" stroke="#d63b1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" d="M0 0 L10 10 L20 5" pointer-events="stroke"/><rect data-id="r1" data-tool="rect" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#fff4d6" x="10" y="20" width="30" height="40"/><ellipse data-id="e1" data-tool="ellipse" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#e6f4ea" cx="100" cy="100" rx="40" ry="25"/><g data-id="a1" data-tool="arrow" stroke="#1a8f3e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none"><line x1="0" y1="0" x2="100" y2="0"/><polyline points="85.5844981135613,6.94213982588093 100,0 85.5844981135613,-6.94213982588093" fill="#1a8f3e"/></g><text data-id="t1" data-tool="text" data-anchor-id="r1" data-font-size="14" fill="#1a1a1a" text-anchor="middle" dominant-baseline="middle">needs work</text></svg>
@@ -0,0 +1,70 @@
1
+ // Unit test for hmr-broadcast.ts classifyChange().
2
+ //
3
+ // The load-bearing case: a canvas/specimen sibling stylesheet (e.g. motion.css
4
+ // next to motion.tsx) is INLINED into the built module — there is no <link> for
5
+ // the iframe css-swap to target, so it must route to a module reload keyed on
6
+ // the sibling .tsx. Link-mounted CSS (tokens / _components / _layout — no
7
+ // sibling .tsx) keeps the fast css swap. Regression guard for the silent-drop
8
+ // bug (see .ai/logs/rca/hmr-inlined-css-dropped.md).
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+
12
+ import { classifyChange } from '../hmr-broadcast.ts';
13
+
14
+ describe('classifyChange', () => {
15
+ const noSibling = () => false;
16
+ const hasSibling = () => true;
17
+
18
+ test('sibling-tsx CSS → module reload keyed on the .tsx', () => {
19
+ const msg = classifyChange(
20
+ 'system/x/preview/motion.css',
21
+ (cssRel) => cssRel === 'system/x/preview/motion.css'
22
+ );
23
+ expect(msg?.mode).toBe('module');
24
+ expect(msg?.file).toBe('system/x/preview/motion.tsx');
25
+ expect(msg?.scope).toBe('canvas');
26
+ });
27
+
28
+ test('link-mounted partial CSS (no sibling .tsx) → css swap', () => {
29
+ expect(classifyChange('system/x/preview/_components.css', noSibling)?.mode).toBe('css');
30
+ expect(classifyChange('system/x/preview/_layout.css', noSibling)?.mode).toBe('css');
31
+ expect(classifyChange('system/x/colors_and_type.css', noSibling)?.mode).toBe('css');
32
+ });
33
+
34
+ test('css mode echoes the changed file path', () => {
35
+ const msg = classifyChange('system/x/colors_and_type.css', noSibling);
36
+ expect(msg?.mode).toBe('css');
37
+ expect(msg?.file).toBe('system/x/colors_and_type.css');
38
+ });
39
+
40
+ test('_lib/** → hard reload (no file)', () => {
41
+ const msg = classifyChange('_lib/canvas-lib.tsx', hasSibling);
42
+ expect(msg?.mode).toBe('hard');
43
+ expect(msg?.scope).toBe('lib');
44
+ expect(msg?.file).toBeUndefined();
45
+ });
46
+
47
+ test('.tsx / .jsx / .ts / .js → module reload', () => {
48
+ for (const f of ['ui/Foo.tsx', 'ui/Foo.jsx', 'ui/Foo.ts', 'ui/Foo.js']) {
49
+ expect(classifyChange(f, noSibling)?.mode).toBe('module');
50
+ expect(classifyChange(f, noSibling)?.file).toBe(f);
51
+ }
52
+ });
53
+
54
+ test('.meta.json → meta (not treated as a .json no-op)', () => {
55
+ const msg = classifyChange('ui/Foo.meta.json', noSibling);
56
+ expect(msg?.mode).toBe('meta');
57
+ expect(msg?.file).toBe('ui/Foo.meta.json');
58
+ });
59
+
60
+ test('unrelated extensions → null', () => {
61
+ expect(classifyChange('ui/notes.md', noSibling)).toBeNull();
62
+ expect(classifyChange('assets/logo.svg', noSibling)).toBeNull();
63
+ });
64
+
65
+ test('backslash paths are normalised before classification', () => {
66
+ const msg = classifyChange('system\\x\\preview\\motion.css', () => true);
67
+ expect(msg?.mode).toBe('module');
68
+ expect(msg?.file).toBe('system/x/preview/motion.tsx');
69
+ });
70
+ });
@@ -229,6 +229,20 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
229
229
  });
230
230
  });
231
231
 
232
+ test('N → tool sticky (Phase 21)', () => {
233
+ expect(classify(base({ type: 'keydown', key: 'n' }))).toEqual({
234
+ kind: 'tool',
235
+ tool: 'sticky',
236
+ });
237
+ });
238
+
239
+ test('T → tool text (Phase 21)', () => {
240
+ expect(classify(base({ type: 'keydown', key: 't' }))).toEqual({
241
+ kind: 'tool',
242
+ tool: 'text',
243
+ });
244
+ });
245
+
232
246
  test('uppercase B (shift held) — still maps to pen (lowercased)', () => {
233
247
  expect(classify(base({ type: 'keydown', key: 'B', shiftKey: true }))).toEqual({
234
248
  kind: 'tool',
@@ -268,6 +282,13 @@ describe('input-router / pointer events — Phase 5 annotation tools', () => {
268
282
  expect(classify(base({ type: 'pointermove', activeTool: 'ellipse' })).kind).toBe('no-op');
269
283
  });
270
284
 
285
+ test('sticky + text tools own bare pointer events (Phase 21 — SVG overlay claims)', () => {
286
+ for (const activeTool of ['sticky', 'text'] as const) {
287
+ expect(classify(base({ type: 'pointerdown', activeTool, button: 0 })).kind).toBe('no-op');
288
+ expect(classify(base({ type: 'pointermove', activeTool })).kind).toBe('no-op');
289
+ }
290
+ });
291
+
271
292
  test('cmd+left-click in arrow tool → select replace (escape hatch to move)', () => {
272
293
  expect(
273
294
  classify(
@@ -0,0 +1,100 @@
1
+ // sanitizeAnnotationSvg — A3 (DDR-060 F1 re-audit). Strips executable constructs
2
+ // from a persisted annotation SVG while leaving the legit presentational
3
+ // vocabulary (strokesToSvg: path/rect/ellipse/g/line/polyline/text) byte-intact.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import { sanitizeAnnotationSvg } from '../api.ts';
8
+
9
+ // The exact shape strokesToSvg() emits — must survive sanitization unchanged.
10
+ const LEGIT =
11
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
12
+ '<path data-id="p1" data-tool="pen" stroke="#d63b1f" stroke-width="2" fill="none" d="M0 0 L10 10" pointer-events="stroke"/>' +
13
+ '<rect data-id="r1" data-tool="rect" stroke="#222" stroke-width="2" fill="none" x="5" y="5" width="20" height="20"/>' +
14
+ '<ellipse data-id="e1" data-tool="ellipse" stroke="#222" stroke-width="2" fill="none" cx="10" cy="10" rx="4" ry="4"/>' +
15
+ '<g data-id="a1" data-tool="arrow" stroke="#222" stroke-width="2" fill="none"><line x1="0" y1="0" x2="9" y2="9"/><polyline points="3,3 9,9 5,7" fill="#222"/></g>' +
16
+ '<text data-id="t1" data-tool="text" data-anchor-id="x" data-font-size="14" fill="#222" text-anchor="middle" dominant-baseline="middle">label</text>' +
17
+ '</svg>';
18
+
19
+ describe('sanitizeAnnotationSvg — A3', () => {
20
+ test('leaves the legit annotation vocabulary byte-identical (zero regression)', () => {
21
+ expect(sanitizeAnnotationSvg(LEGIT)).toBe(LEGIT);
22
+ });
23
+
24
+ test('strips <script> blocks', () => {
25
+ const dirty = LEGIT.replace('</svg>', '<script>fetch("/_api/export")</script></svg>');
26
+ const out = sanitizeAnnotationSvg(dirty);
27
+ expect(out).not.toContain('<script');
28
+ expect(out).not.toContain('fetch("/_api/export")');
29
+ });
30
+
31
+ test('strips <foreignObject> (the SVG-XSS smuggle)', () => {
32
+ const dirty = LEGIT.replace(
33
+ '</svg>',
34
+ '<foreignObject><div onload="x"></div></foreignObject></svg>'
35
+ );
36
+ const out = sanitizeAnnotationSvg(dirty);
37
+ expect(out.toLowerCase()).not.toContain('foreignobject');
38
+ });
39
+
40
+ test('strips inline event-handler attributes (onload/onclick/…)', () => {
41
+ const dirty =
42
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
43
+ '<rect x="0" y="0" width="9" height="9" onload="alert(1)" onclick=\'evil()\'/>' +
44
+ '</svg>';
45
+ const out = sanitizeAnnotationSvg(dirty);
46
+ expect(out).not.toMatch(/\son\w+\s*=/i);
47
+ expect(out).toContain('<rect');
48
+ expect(out).toContain('width="9"');
49
+ });
50
+
51
+ test('strips <image>/<use> and javascript: URLs', () => {
52
+ const dirty =
53
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
54
+ '<image href="javascript:alert(1)"/><use href="#x"/>' +
55
+ '</svg>';
56
+ const out = sanitizeAnnotationSvg(dirty);
57
+ expect(out.toLowerCase()).not.toContain('<image');
58
+ expect(out.toLowerCase()).not.toContain('<use');
59
+ expect(out.toLowerCase()).not.toContain('javascript:');
60
+ });
61
+
62
+ // Bypasses the F1 confirming pass found against the prior denylist — the
63
+ // allowlist must close all of them.
64
+ test('drops namespaced <svg:script> (denylist missed this)', () => {
65
+ const out = sanitizeAnnotationSvg(
66
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><svg:script>alert(1)</svg:script></svg>'
67
+ );
68
+ expect(out.toLowerCase()).not.toContain('<svg:script');
69
+ expect(out.toLowerCase()).not.toContain('script>');
70
+ });
71
+
72
+ test('drops <style> (CSS @import / url(javascript:) exfil lane)', () => {
73
+ const out = sanitizeAnnotationSvg(
74
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><style>@import url("//evil")</style></svg>'
75
+ );
76
+ expect(out.toLowerCase()).not.toContain('<style');
77
+ });
78
+
79
+ test('strips style= (CSS url(javascript:)) and entity-encoded xlink:href on survivors', () => {
80
+ const out = sanitizeAnnotationSvg(
81
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
82
+ '<rect x="0" y="0" width="9" height="9" style="fill:url(javascript:alert(1))"/>' +
83
+ '<text xlink:href="javascript&#58;alert(1)">hi</text></svg>'
84
+ );
85
+ expect(out).not.toMatch(/\sstyle\s*=/i);
86
+ expect(out).not.toMatch(/href\s*=/i);
87
+ // The legit elements + safe attrs survive.
88
+ expect(out).toContain('<rect');
89
+ expect(out).toContain('width="9"');
90
+ expect(out).toContain('<text');
91
+ });
92
+
93
+ test('drops <set>/<animate> animation elements (attribute-name vector)', () => {
94
+ const out = sanitizeAnnotationSvg(
95
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect><set attributeName="onload" to="alert(1)"/></rect></svg>'
96
+ );
97
+ expect(out.toLowerCase()).not.toContain('<set');
98
+ expect(out.toLowerCase()).not.toContain('<animate');
99
+ });
100
+ });