@1agh/maude 0.20.0 → 0.22.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 (71) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +361 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +245 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +16 -8
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
  23. package/plugins/design/dev-server/annotations-layer.tsx +52 -12
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
  26. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  27. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  28. package/plugins/design/dev-server/canvas-lib.tsx +33 -7
  29. package/plugins/design/dev-server/canvas-shell.tsx +127 -9
  30. package/plugins/design/dev-server/client/app.jsx +72 -0
  31. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  32. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  33. package/plugins/design/dev-server/collab/index.ts +47 -0
  34. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  35. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  36. package/plugins/design/dev-server/collab/registry.ts +110 -0
  37. package/plugins/design/dev-server/collab/room.ts +215 -0
  38. package/plugins/design/dev-server/comments-overlay.tsx +29 -0
  39. package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
  40. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  41. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  42. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +624 -0
  43. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
  44. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
  45. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
  46. package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -0
  47. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  48. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  49. package/plugins/design/dev-server/http.ts +64 -1
  50. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  51. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  52. package/plugins/design/dev-server/runtime-bundle.ts +19 -0
  53. package/plugins/design/dev-server/server.ts +78 -11
  54. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  55. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  56. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  57. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  58. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  59. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  60. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  61. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  62. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  63. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  64. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  65. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  66. package/plugins/design/dev-server/use-collab.tsx +478 -0
  67. package/plugins/design/dev-server/ws.ts +123 -7
  68. package/plugins/design/templates/_shell.html +37 -1
  69. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  70. package/plugins/flow/dependencies.json +143 -0
  71. package/plugins/flow/dependencies.schema.json +107 -0
@@ -0,0 +1,121 @@
1
+ // Phase 8 Task 8 — multi-tab stress harness.
2
+ //
3
+ // Two in-memory peers attached to one Room, broadcasting cursor-shaped
4
+ // Awareness updates at ~30 Hz for `STRESS_MS` (default 10 s in CI; the plan
5
+ // spec calls for 2 min but a tighter CI default keeps the run under 15 s).
6
+ // Measures:
7
+ //
8
+ // - RSS growth via process.memoryUsage().rss (before vs after)
9
+ // - Y.Doc state size growth via Y.encodeStateAsUpdate(doc).byteLength
10
+ // - Awareness state cleanup — when both peers disconnect, no entries leak
11
+ //
12
+ // Pass thresholds (CI-safe):
13
+ // - RSS delta < 20 MB (plan spec; conservative because GC is non-deterministic)
14
+ // - Y.Doc growth < 100 KB (the spec ceiling is 500 KB for a 2-min run; with
15
+ // awareness ephemeral + no doc.update traffic, growth should be ~0).
16
+
17
+ import { afterEach, describe, expect, test } from 'bun:test';
18
+
19
+ import { Awareness } from 'y-protocols/awareness';
20
+ import * as Y from 'yjs';
21
+
22
+ import { type RoomConn, createRoom } from '../collab/room.ts';
23
+
24
+ const STRESS_MS = Number(process.env.MAUDE_STRESS_MS ?? 10_000);
25
+ const RSS_GROWTH_LIMIT_MB = 20;
26
+ const YDOC_GROWTH_LIMIT_BYTES = 100_000;
27
+
28
+ function makeConn(id: string): RoomConn {
29
+ return {
30
+ id,
31
+ send() {
32
+ /* drain — we don't care about the network bytes; the memory pressure
33
+ is on the Awareness map + Y.Doc internals, not the WS buffer */
34
+ },
35
+ };
36
+ }
37
+
38
+ describe('multi-tab stress (Phase 8 Task 8)', () => {
39
+ let cleanupRoom: (() => Promise<void>) | null = null;
40
+
41
+ afterEach(async () => {
42
+ if (cleanupRoom) {
43
+ await cleanupRoom();
44
+ cleanupRoom = null;
45
+ }
46
+ });
47
+
48
+ test(
49
+ '30 Hz × STRESS_MS × 2 peers — bounded RSS + Y.Doc growth',
50
+ async () => {
51
+ const room = createRoom('stress-slug', {
52
+ async seed() {},
53
+ async persistJson() {},
54
+ async persistBinary() {},
55
+ });
56
+ cleanupRoom = () => room.destroy();
57
+
58
+ const A = makeConn('peer-a');
59
+ const B = makeConn('peer-b');
60
+ await room.connect(A);
61
+ await room.connect(B);
62
+
63
+ // Each peer has its own local Awareness instance to model what real
64
+ // clients do — they don't share a memory address space with the server's
65
+ // awareness. We pump updates by setting state on the server-side
66
+ // awareness directly (the server's room.awareness is what the real WS
67
+ // peers' setLocalState() would land in via applyAwarenessUpdate).
68
+ const awA = new Awareness(new Y.Doc());
69
+ const awB = new Awareness(new Y.Doc());
70
+ awA.setLocalState({ name: 'Alice', color: '#f00', cursor: { x: 0, y: 0 }, __connId: A.id });
71
+ awB.setLocalState({ name: 'Bob', color: '#0f0', cursor: { x: 0, y: 0 }, __connId: B.id });
72
+
73
+ const rssBefore = process.memoryUsage().rss;
74
+ const docSizeBefore = Y.encodeStateAsUpdate(room.doc).byteLength;
75
+
76
+ const start = Date.now();
77
+ const tickInterval = 33; // ~30 Hz
78
+ let aX = 0;
79
+ let bX = 0;
80
+ let updates = 0;
81
+ while (Date.now() - start < STRESS_MS) {
82
+ aX = (aX + 1) % 1000;
83
+ bX = (bX + 7) % 1000;
84
+ awA.setLocalState({
85
+ name: 'Alice',
86
+ color: '#f00',
87
+ cursor: { x: aX, y: aX },
88
+ __connId: A.id,
89
+ });
90
+ awB.setLocalState({
91
+ name: 'Bob',
92
+ color: '#0f0',
93
+ cursor: { x: bX, y: bX },
94
+ __connId: B.id,
95
+ });
96
+ updates += 2;
97
+ await Bun.sleep(tickInterval);
98
+ }
99
+
100
+ const rssAfter = process.memoryUsage().rss;
101
+ const docSizeAfter = Y.encodeStateAsUpdate(room.doc).byteLength;
102
+
103
+ const rssGrowthMb = (rssAfter - rssBefore) / (1024 * 1024);
104
+ const docGrowth = docSizeAfter - docSizeBefore;
105
+
106
+ console.log(
107
+ `[stress] ${updates} awareness updates over ${STRESS_MS} ms → ` +
108
+ `RSS Δ=${rssGrowthMb.toFixed(1)} MB, Y.Doc Δ=${docGrowth} bytes`
109
+ );
110
+
111
+ expect(rssGrowthMb).toBeLessThan(RSS_GROWTH_LIMIT_MB);
112
+ expect(docGrowth).toBeLessThan(YDOC_GROWTH_LIMIT_BYTES);
113
+
114
+ // Disconnect both peers; size should go to 0.
115
+ room.disconnect(A);
116
+ room.disconnect(B);
117
+ expect(room.size()).toBe(0);
118
+ },
119
+ STRESS_MS + 5_000
120
+ );
121
+ });
@@ -0,0 +1,101 @@
1
+ // Unit: Task 7 — `.git/HEAD` watcher. We don't exercise fs.watch directly
2
+ // (flaky across platforms in CI); instead we drive the module's public
3
+ // surface: it gracefully no-ops when no .git/ exists, and the public stop()
4
+ // is idempotent.
5
+ //
6
+ // The flush-before-broadcast invariant is verified separately by the
7
+ // collab-bridge tests and the persistence module's contract — registry
8
+ // already exposes flushAll(). The integration with .git/HEAD writes is
9
+ // exercised manually in the Task 7 acceptance test (Phase 8 plan validate row).
10
+
11
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
16
+
17
+ import { createGitLifecycle } from '../collab/git-lifecycle.ts';
18
+ import { createRegistry } from '../collab/registry.ts';
19
+ import type { RoomCallbacks } from '../collab/room.ts';
20
+ import { type Context, createBus } from '../context.ts';
21
+
22
+ function ctxFor(repoRoot: string): Context {
23
+ return {
24
+ cfg: {} as Context['cfg'],
25
+ projectLabel: 'test',
26
+ paths: {
27
+ repoRoot,
28
+ designRel: '.design',
29
+ designRoot: path.join(repoRoot, '.design'),
30
+ serverInfoFile: '',
31
+ activeFile: '',
32
+ commentsDir: '',
33
+ canvasStateDir: '',
34
+ historyDir: '',
35
+ tokensUrlRel: '',
36
+ systemDirRel: '',
37
+ },
38
+ bus: createBus(),
39
+ };
40
+ }
41
+
42
+ function noopCallbacks(): RoomCallbacks {
43
+ return { async seed() {}, async persistJson() {}, async persistBinary() {} };
44
+ }
45
+
46
+ describe('git-lifecycle', () => {
47
+ let tmp: string;
48
+
49
+ beforeEach(() => {
50
+ tmp = mkdtempSync(path.join(tmpdir(), 'maude-git-lifecycle-'));
51
+ });
52
+
53
+ afterEach(() => {
54
+ try {
55
+ rmSync(tmp, { recursive: true, force: true });
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ });
60
+
61
+ test('no .git/ directory → silent no-op (still returns a stop function)', () => {
62
+ const ctx = ctxFor(tmp);
63
+ const registry = createRegistry(noopCallbacks());
64
+ expect(existsSync(path.join(tmp, '.git'))).toBe(false);
65
+ const gl = createGitLifecycle(ctx, registry);
66
+ expect(typeof gl.stop).toBe('function');
67
+ gl.stop(); // idempotent
68
+ gl.stop();
69
+ });
70
+
71
+ test('with .git/HEAD present → watcher attaches without throwing', () => {
72
+ mkdirSync(path.join(tmp, '.git'), { recursive: true });
73
+ writeFileSync(path.join(tmp, '.git', 'HEAD'), 'ref: refs/heads/main\n');
74
+ const ctx = ctxFor(tmp);
75
+ const registry = createRegistry(noopCallbacks());
76
+ const gl = createGitLifecycle(ctx, registry);
77
+ expect(typeof gl.stop).toBe('function');
78
+ gl.stop();
79
+ });
80
+
81
+ test('emits git-lifecycle bus event on HEAD change (manual rewrite)', async () => {
82
+ mkdirSync(path.join(tmp, '.git'), { recursive: true });
83
+ writeFileSync(path.join(tmp, '.git', 'HEAD'), 'ref: refs/heads/main\n');
84
+ const ctx = ctxFor(tmp);
85
+ const registry = createRegistry(noopCallbacks());
86
+ const events: unknown[] = [];
87
+ ctx.bus.on('git-lifecycle', (e) => events.push(e));
88
+ const gl = createGitLifecycle(ctx, registry);
89
+
90
+ // Rewrite HEAD; the watcher fires + 250 ms debounce + flush. Wait > 500 ms
91
+ // to be safe with fs.watch + flushAll latency on macOS + Linux.
92
+ writeFileSync(path.join(tmp, '.git', 'HEAD'), 'ref: refs/heads/other\n');
93
+ await Bun.sleep(700);
94
+
95
+ expect(events.length).toBeGreaterThanOrEqual(1);
96
+ const evt = events[0] as { reason?: string; head?: string };
97
+ expect(evt.reason).toBe('head-changed');
98
+ expect(evt.head).toContain('refs/heads/other');
99
+ gl.stop();
100
+ });
101
+ });
@@ -0,0 +1,30 @@
1
+ // Unit: pure helpers from participants-chrome.tsx.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { initialsFor } from '../participants-chrome.tsx';
6
+
7
+ describe('initialsFor', () => {
8
+ test('two-word name → uppercase first letters', () => {
9
+ expect(initialsFor('Alice Smith')).toBe('AS');
10
+ expect(initialsFor('michał dovrtěl')).toBe('MD');
11
+ });
12
+
13
+ test('single-word name → first two letters uppercased', () => {
14
+ expect(initialsFor('alice')).toBe('AL');
15
+ expect(initialsFor('A')).toBe('A');
16
+ });
17
+
18
+ test('many-word name → first letter of first two words', () => {
19
+ expect(initialsFor('John Ronald Reuel Tolkien')).toBe('JR');
20
+ });
21
+
22
+ test('empty / whitespace → ?', () => {
23
+ expect(initialsFor('')).toBe('?');
24
+ expect(initialsFor(' ')).toBe('?');
25
+ });
26
+
27
+ test('handles extra whitespace', () => {
28
+ expect(initialsFor(' Alice Smith ')).toBe('AS');
29
+ });
30
+ });
@@ -0,0 +1,71 @@
1
+ // Unit: pure helpers from use-collab.tsx (color hash + slug derivation).
2
+ // The provider itself + the WS round-trip live in browser-shaped harnesses.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { canvasSlugFromPath, colorForName } from '../use-collab.tsx';
7
+
8
+ describe('colorForName', () => {
9
+ test('returns a color from the curated palette', () => {
10
+ const color = colorForName('Alice');
11
+ expect(color).toMatch(/^#[0-9a-f]{6}$/i);
12
+ });
13
+
14
+ test('deterministic per input', () => {
15
+ const a = colorForName('Alice');
16
+ const b = colorForName('Alice');
17
+ expect(a).toBe(b);
18
+ });
19
+
20
+ test('different names land on different colors (probabilistically)', () => {
21
+ // Sample many distinct names; expect at least 8 distinct colors among 30
22
+ // names. With 12-color palette + uniform-ish djb2, this is comfortable.
23
+ const names = Array.from({ length: 30 }, (_, i) => `peer-${i}`);
24
+ const colors = new Set(names.map(colorForName));
25
+ expect(colors.size).toBeGreaterThanOrEqual(8);
26
+ });
27
+
28
+ test('empty string returns a palette color (not crash)', () => {
29
+ expect(colorForName('')).toMatch(/^#[0-9a-f]{6}$/i);
30
+ });
31
+
32
+ test('unicode names work', () => {
33
+ expect(colorForName('Michał Dovrtěl')).toMatch(/^#[0-9a-f]{6}$/i);
34
+ expect(colorForName('佐藤')).toMatch(/^#[0-9a-f]{6}$/i);
35
+ });
36
+ });
37
+
38
+ describe('canvasSlugFromPath', () => {
39
+ test('normalizes a typical canvas path', () => {
40
+ expect(canvasSlugFromPath('ui/Foo.tsx')).toBe('ui-foo');
41
+ expect(canvasSlugFromPath('ui/Canvas Viewport.tsx')).toBe('ui-canvas_viewport');
42
+ expect(canvasSlugFromPath('system/project/preview/colors-accent.tsx')).toBe(
43
+ 'system-project-preview-colors-accent'
44
+ );
45
+ });
46
+
47
+ test('strips .tsx + .html extensions case-insensitively', () => {
48
+ expect(canvasSlugFromPath('ui/Foo.TSX')).toBe('ui-foo');
49
+ expect(canvasSlugFromPath('ui/Foo.html')).toBe('ui-foo');
50
+ });
51
+
52
+ test('returns null for paths that would yield invalid slug chars', () => {
53
+ // Dots in name (not extension) survive normalization and fail the gate.
54
+ expect(canvasSlugFromPath('ui/Foo.Bar.tsx')).toBeNull();
55
+ // Empty input.
56
+ expect(canvasSlugFromPath('')).toBeNull();
57
+ expect(canvasSlugFromPath(null)).toBeNull();
58
+ expect(canvasSlugFromPath(undefined)).toBeNull();
59
+ });
60
+
61
+ test('round-trips against the slug grammar parseCollabSlug accepts', () => {
62
+ // parseCollabSlug regex = ^[a-z0-9_-]+$
63
+ const re = /^[a-z0-9_-]+$/;
64
+ const samples = ['ui/Foo.tsx', 'system/project/preview/x.tsx', 'a/b c.tsx'];
65
+ for (const s of samples) {
66
+ const slug = canvasSlugFromPath(s);
67
+ expect(slug).not.toBeNull();
68
+ if (slug !== null) expect(re.test(slug)).toBe(true);
69
+ }
70
+ });
71
+ });
@@ -76,19 +76,19 @@ const PALETTE_CSS = `
76
76
  transition: background-color 80ms linear, color 80ms linear;
77
77
  }
78
78
  .dc-tool-palette button:hover {
79
- background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
79
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
80
80
  color: var(--fg-0, #1a1a1a);
81
81
  }
82
82
  .dc-tool-palette button:focus-visible {
83
- outline: 2px solid var(--accent, #d63b1f);
83
+ outline: 2px solid var(--maude-hud-accent, #d63b1f);
84
84
  outline-offset: -2px;
85
85
  }
86
86
  /* DDR-046 — Active tool: tinted background + accent underbar + accent text.
87
87
  The underbar is rendered via ::after so the visual stays inside the 6 px
88
88
  radius without leaking past the button edge. */
89
89
  .dc-tool-palette button[aria-pressed="true"] {
90
- background: color-mix(in oklab, var(--accent, #d63b1f) 14%, transparent);
91
- color: var(--accent, #d63b1f);
90
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
91
+ color: var(--maude-hud-accent, #d63b1f);
92
92
  }
93
93
  .dc-tool-palette button[aria-pressed="true"]::after {
94
94
  content: "";
@@ -97,7 +97,7 @@ const PALETTE_CSS = `
97
97
  right: 6px;
98
98
  bottom: 2px;
99
99
  height: 2px;
100
- background: var(--accent, #d63b1f);
100
+ background: var(--maude-hud-accent, #d63b1f);
101
101
  border-radius: 1px;
102
102
  }
103
103
  /* T19 — sticky-tool lock badge. Tiny accent square in the top-right corner
@@ -107,7 +107,7 @@ const PALETTE_CSS = `
107
107
  .dc-tool-palette button[data-sticky="true"] {
108
108
  /* Keep the tinted active background; add a hairline ring so the lock state
109
109
  is readable even when the button is also aria-pressed. */
110
- box-shadow: inset 0 0 0 1px var(--accent, #d63b1f);
110
+ box-shadow: inset 0 0 0 1px var(--maude-hud-accent, #d63b1f);
111
111
  }
112
112
  .dc-tool-palette button .dc-tp-sticky-badge {
113
113
  position: absolute;
@@ -115,7 +115,7 @@ const PALETTE_CSS = `
115
115
  right: 3px;
116
116
  width: 6px;
117
117
  height: 6px;
118
- background: var(--accent, #d63b1f);
118
+ background: var(--maude-hud-accent, #d63b1f);
119
119
  border-radius: 1px;
120
120
  box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
121
121
  opacity: 0;
@@ -34,7 +34,7 @@ const RESIZE_CSS = `
34
34
  position: fixed;
35
35
  width: 8px;
36
36
  height: 8px;
37
- background: var(--accent, #d63b1f);
37
+ background: var(--maude-hud-accent, #d63b1f);
38
38
  border: 1px solid var(--bg-0, #ffffff);
39
39
  border-radius: 1px;
40
40
  box-shadow: 0 0 0 0.5px color-mix(in oklab, var(--fg-0, #1c1917) 30%, transparent);