@1agh/maude 0.20.0 → 0.21.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 (69) 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 +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -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/y-protocols_awareness.js +587 -0
  43. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  44. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  45. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  46. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  47. package/plugins/design/dev-server/http.ts +64 -1
  48. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  49. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  50. package/plugins/design/dev-server/runtime-bundle.ts +8 -0
  51. package/plugins/design/dev-server/server.ts +78 -11
  52. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  53. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  54. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  55. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  56. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  57. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  58. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  59. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  60. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  61. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  62. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  63. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  64. package/plugins/design/dev-server/use-collab.tsx +478 -0
  65. package/plugins/design/dev-server/ws.ts +123 -7
  66. package/plugins/design/templates/_shell.html +35 -1
  67. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  68. package/plugins/flow/dependencies.json +143 -0
  69. package/plugins/flow/dependencies.schema.json +107 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,18 +41,22 @@
41
41
  "prepublishOnly": "bash scripts/check-version-parity.sh"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@1agh/maude-darwin-arm64": "0.20.0",
45
- "@1agh/maude-darwin-x64": "0.20.0",
46
- "@1agh/maude-linux-arm64": "0.20.0",
47
- "@1agh/maude-linux-arm64-musl": "0.20.0",
48
- "@1agh/maude-linux-x64": "0.20.0",
49
- "@1agh/maude-linux-x64-musl": "0.20.0",
50
- "@1agh/maude-win32-x64": "0.20.0"
44
+ "@1agh/maude-darwin-arm64": "0.21.0",
45
+ "@1agh/maude-darwin-x64": "0.21.0",
46
+ "@1agh/maude-linux-arm64": "0.21.0",
47
+ "@1agh/maude-linux-arm64-musl": "0.21.0",
48
+ "@1agh/maude-linux-x64": "0.21.0",
49
+ "@1agh/maude-linux-x64-musl": "0.21.0",
50
+ "@1agh/maude-win32-x64": "0.21.0"
51
51
  },
52
52
  "files": [
53
53
  "cli",
54
54
  "plugins/design/dev-server",
55
+ "plugins/design/dependencies.json",
56
+ "plugins/design/dependencies.schema.json",
55
57
  "plugins/design/templates",
58
+ "plugins/flow/dependencies.json",
59
+ "plugins/flow/dependencies.schema.json",
56
60
  "plugins/flow/templates",
57
61
  "plugins/flow/.claude-plugin/config.schema.json",
58
62
  "LICENSE",
@@ -84,5 +88,9 @@
84
88
  "@biomejs/biome": "^1.9.4",
85
89
  "@changesets/cli": "^2.27.10",
86
90
  "@playwright/test": "^1.60.0"
91
+ },
92
+ "dependencies": {
93
+ "ajv": "^8.20.0",
94
+ "ajv-formats": "^3.0.1"
87
95
  }
88
96
  }
@@ -0,0 +1,147 @@
1
+ {
2
+ "$schema": "./dependencies.schema.json",
3
+ "version": "1",
4
+ "plugin": "design",
5
+ "dependencies": [
6
+ {
7
+ "id": "node",
8
+ "type": "cli",
9
+ "hardness": "hard",
10
+ "check": {
11
+ "command": "node --version",
12
+ "expectExit": 0,
13
+ "minVersion": "20.0.0"
14
+ },
15
+ "install": {
16
+ "preferred": "https://nodejs.org/en/download",
17
+ "darwin": "brew install node@22",
18
+ "linux": "https://nodejs.org/en/download/package-manager"
19
+ },
20
+ "autoInstall": false,
21
+ "usedBy": ["commands/init.md", "commands/browse.md", "dev-server/server.ts"],
22
+ "docsUrl": "https://nodejs.org"
23
+ },
24
+ {
25
+ "id": "git",
26
+ "type": "cli",
27
+ "hardness": "hard",
28
+ "check": {
29
+ "command": "git --version",
30
+ "expectExit": 0
31
+ },
32
+ "install": {
33
+ "darwin": "brew install git",
34
+ "linux": "apt-get install -y git || dnf install -y git"
35
+ },
36
+ "autoInstall": false,
37
+ "usedBy": ["commands/init.md", "dev-server/collab/git-lifecycle.ts"],
38
+ "docsUrl": "https://git-scm.com"
39
+ },
40
+ {
41
+ "id": "bun",
42
+ "type": "cli",
43
+ "hardness": "hard",
44
+ "check": {
45
+ "command": "bun --version",
46
+ "expectExit": 0,
47
+ "minVersion": "1.3.0"
48
+ },
49
+ "install": {
50
+ "preferred": "curl -fsSL https://bun.sh/install | bash",
51
+ "darwin": "brew install oven-sh/bun/bun",
52
+ "win32": "powershell -c \"irm bun.sh/install.ps1 | iex\""
53
+ },
54
+ "autoInstall": false,
55
+ "fallbackBehavior": "dev-server is migrating to Bun authoritatively (DDR-009). Some build steps (bun run build.ts, bun test) require Bun and have no Node fallback.",
56
+ "usedBy": ["dev-server/package.json", "dev-server/build.ts", "dev-server/bin/smoke.sh"],
57
+ "docsUrl": "https://bun.com"
58
+ },
59
+ {
60
+ "id": "agent-browser",
61
+ "type": "cli",
62
+ "hardness": "soft",
63
+ "check": {
64
+ "command": "agent-browser --version",
65
+ "expectExit": 0
66
+ },
67
+ "install": {
68
+ "preferred": "npm i -g @anthropic-ai/agent-browser"
69
+ },
70
+ "autoInstall": true,
71
+ "fallbackBehavior": "Screenshot helpers fall back to `npx playwright` (slower, less reliable). /design:smoke and several critics need agent-browser for full coverage.",
72
+ "usedBy": [
73
+ "commands/screenshot.md",
74
+ "commands/new.md",
75
+ "commands/edit.md",
76
+ "commands/smoke.md",
77
+ "dev-server/bin/screenshot.sh",
78
+ "dev-server/bin/smoke.sh",
79
+ "agents/design-critic.md",
80
+ "agents/graphic-design-critic.md",
81
+ "agents/a11y-critic.md"
82
+ ],
83
+ "docsUrl": "https://github.com/anthropics/agent-browser"
84
+ },
85
+ {
86
+ "id": "playwright",
87
+ "type": "cli",
88
+ "hardness": "soft",
89
+ "check": {
90
+ "command": "npx --no-install playwright --version",
91
+ "expectExit": 0
92
+ },
93
+ "install": {
94
+ "preferred": "npm i -g playwright && npx playwright install chromium"
95
+ },
96
+ "autoInstall": false,
97
+ "fallbackBehavior": "Screenshot scripts use playwright as a fallback when agent-browser is missing. Without either, screenshot helpers will fail.",
98
+ "usedBy": ["dev-server/bin/_screenshot-playwright.mjs", "dev-server/bin/screenshot.sh"],
99
+ "docsUrl": "https://playwright.dev"
100
+ },
101
+ {
102
+ "id": "maude",
103
+ "type": "cli",
104
+ "hardness": "soft",
105
+ "check": {
106
+ "command": "maude --version",
107
+ "expectExit": 0
108
+ },
109
+ "install": {
110
+ "preferred": "npm i -g @1agh/maude"
111
+ },
112
+ "autoInstall": false,
113
+ "fallbackBehavior": "Slash commands still work; `maude` CLI provides faster scaffolding (init / config / design serve) without going through Claude.",
114
+ "usedBy": ["commands/init.md", "skills/design/SKILL.md"],
115
+ "docsUrl": "https://github.com/1aGh/maude"
116
+ },
117
+ {
118
+ "id": "jq",
119
+ "type": "system-tool",
120
+ "hardness": "soft",
121
+ "check": {
122
+ "command": "jq --version",
123
+ "expectExit": 0
124
+ },
125
+ "install": {
126
+ "darwin": "brew install jq",
127
+ "linux": "apt-get install -y jq || dnf install -y jq",
128
+ "win32": "winget install jqlang.jq"
129
+ },
130
+ "autoInstall": false,
131
+ "fallbackBehavior": "Slash commands' bash blocks that read .ai/workflows.config.json via jq degrade to inline node one-liners; harder to read but functional.",
132
+ "usedBy": ["dev-server/bin/bootstrap-check.sh", "dev-server/bin/server-up.sh"]
133
+ },
134
+ {
135
+ "id": "askuserquestion-mcp",
136
+ "type": "mcp",
137
+ "hardness": "soft",
138
+ "check": {
139
+ "mcp": "AskUserQuestion",
140
+ "tool": "ask"
141
+ },
142
+ "fallbackBehavior": "Discovery + critic-panel rounds fall back to numbered-prose chat. Slower, fewer options per round, no preview side-pane.",
143
+ "usedBy": ["skills/design-system/SKILL.md", "commands/setup-ds.md", "commands/new.md"],
144
+ "docsUrl": "https://docs.claude.com/en/docs/claude-code"
145
+ }
146
+ ]
147
+ }
@@ -0,0 +1,107 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "maude plugin — runtime dependency manifest",
4
+ "description": "Declares CLI binaries, MCP servers, and runtime packages the plugin's commands / agents / skills shell out to. Consumed by maude doctor (cli/commands/doctor.mjs) and preflight.sh / preflight.mjs. Schema is shared between plugins — each plugin ships its own dependencies.json + (symlink to) this schema.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["version", "plugin", "dependencies"],
8
+ "properties": {
9
+ "version": {
10
+ "type": "string",
11
+ "const": "1",
12
+ "description": "Manifest schema version. Bumped when the dependency entry shape changes incompatibly."
13
+ },
14
+ "plugin": {
15
+ "type": "string",
16
+ "minLength": 1,
17
+ "description": "Plugin name (matches plugins/<plugin>/.claude-plugin/plugin.json `name`)."
18
+ },
19
+ "dependencies": {
20
+ "type": "array",
21
+ "minItems": 0,
22
+ "items": { "$ref": "#/$defs/Dependency" }
23
+ }
24
+ },
25
+ "$defs": {
26
+ "Dependency": {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "required": ["id", "type", "hardness", "check"],
30
+ "properties": {
31
+ "id": {
32
+ "type": "string",
33
+ "pattern": "^[a-z0-9][a-z0-9._-]*$",
34
+ "description": "Unique identifier within the manifest. Lowercase, hyphenated."
35
+ },
36
+ "type": {
37
+ "type": "string",
38
+ "enum": ["cli", "mcp", "node-package", "bun-package", "system-tool"],
39
+ "description": "What kind of dependency. `cli` = on PATH; `mcp` = MCP server / tool; `node-package` / `bun-package` = installed via npm/bun; `system-tool` = OS-provided (curl, jq)."
40
+ },
41
+ "hardness": {
42
+ "type": "string",
43
+ "enum": ["hard", "soft"],
44
+ "description": "`hard` = missing blocks plugin use; `soft` = graceful degradation possible (fallback path, warning only)."
45
+ },
46
+ "check": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "description": "How to detect presence. For CLI/system-tool/package: shell command + expected exit. For MCP: tool name to probe.",
50
+ "properties": {
51
+ "command": {
52
+ "type": "string",
53
+ "description": "Shell command executed via `bash -c`. Pipes allowed. Defaults to `<id> --version`."
54
+ },
55
+ "expectExit": {
56
+ "type": "integer",
57
+ "default": 0,
58
+ "description": "Expected exit code. Anything else = missing/broken."
59
+ },
60
+ "minVersion": {
61
+ "type": "string",
62
+ "description": "Minimum semver. Comparator extracts `vX.Y.Z` from check.command stdout."
63
+ },
64
+ "mcp": {
65
+ "type": "string",
66
+ "description": "MCP server name. For type=mcp only."
67
+ },
68
+ "tool": {
69
+ "type": "string",
70
+ "description": "Specific tool name on the MCP server. For type=mcp only."
71
+ }
72
+ }
73
+ },
74
+ "install": {
75
+ "type": "object",
76
+ "additionalProperties": false,
77
+ "description": "Install commands per platform. `preferred` is OS-agnostic / fallback; per-platform keys override.",
78
+ "properties": {
79
+ "preferred": { "type": "string" },
80
+ "darwin": { "type": "string" },
81
+ "linux": { "type": "string" },
82
+ "win32": { "type": "string" }
83
+ }
84
+ },
85
+ "autoInstall": {
86
+ "type": "boolean",
87
+ "default": false,
88
+ "description": "If true, `maude doctor --fix` may run install.preferred after per-dep confirmation prompt."
89
+ },
90
+ "fallbackBehavior": {
91
+ "type": "string",
92
+ "description": "What the plugin does when this soft dep is missing. Human-readable; surfaces in maude doctor's report."
93
+ },
94
+ "usedBy": {
95
+ "type": "array",
96
+ "items": { "type": "string" },
97
+ "description": "Repo-relative paths (or path globs) of plugin files that reference this dependency. Aids debugging — doctor surfaces this when explaining a miss."
98
+ },
99
+ "docsUrl": {
100
+ "type": "string",
101
+ "format": "uri",
102
+ "description": "Where to read more."
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @file ai-banner.tsx — yellow "Claude is editing this canvas" banner
3
+ * @scope plugins/design/dev-server/ai-banner.tsx
4
+ * @purpose Mounted by canvas-shell. Subscribes to the inspector WS
5
+ * `ai-activity` event for the canvas's own file path; shows a
6
+ * non-modal yellow banner while AI work is in progress and clears
7
+ * on explicit /end OR 30-second heartbeat-grace expiry.
8
+ *
9
+ * The banner is a soft notice, NOT a lock — the user can still type, click,
10
+ * drag annotations, etc. It exists so the user doesn't unknowingly fight a
11
+ * `/design:edit` that's about to rewrite their HTML.
12
+ */
13
+
14
+ import { useEffect, useState } from 'react';
15
+
16
+ function deriveFile(): string | null {
17
+ if (typeof window === 'undefined') return null;
18
+ try {
19
+ const p = window.location.pathname;
20
+ if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
21
+ const qs = new URLSearchParams(window.location.search);
22
+ const canvas = qs.get('canvas') ?? '';
23
+ const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
24
+ return canvas ? `${designRel}/${canvas}` : null;
25
+ }
26
+ return decodeURIComponent(p).replace(/^\//, '');
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ const BANNER_CSS = `
33
+ .dc-ai-banner {
34
+ position: fixed;
35
+ top: 14px;
36
+ left: 50%;
37
+ transform: translateX(-50%);
38
+ z-index: 10001;
39
+ padding: 8px 14px;
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 10px;
43
+ background: var(--maude-hud-accent-tint, oklch(92% 0.040 55));
44
+ color: var(--fg-0, #111);
45
+ border: 1px solid var(--border-default, rgba(0,0,0,0.16));
46
+ border-radius: var(--radius-md, 4px);
47
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
48
+ font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
49
+ font-weight: 500;
50
+ font-size: 12px;
51
+ line-height: 1.2;
52
+ letter-spacing: 0.01em;
53
+ pointer-events: none;
54
+ user-select: none;
55
+ }
56
+ .dc-ai-banner__dot {
57
+ width: 7px;
58
+ height: 7px;
59
+ border-radius: 50%;
60
+ background: var(--maude-hud-accent, oklch(56% 0.170 50));
61
+ flex: 0 0 auto;
62
+ animation: dc-ai-banner-pulse 1.4s ease-in-out infinite;
63
+ }
64
+ @keyframes dc-ai-banner-pulse {
65
+ 0%, 100% { opacity: 1; transform: scale(1); }
66
+ 50% { opacity: 0.5; transform: scale(0.75); }
67
+ }
68
+ @media (prefers-reduced-motion: reduce) {
69
+ .dc-ai-banner__dot { animation: none; }
70
+ }
71
+ `.trim();
72
+
73
+ function ensureBannerStyles(): void {
74
+ if (typeof document === 'undefined') return;
75
+ if (document.getElementById('dc-ai-banner-css')) return;
76
+ const s = document.createElement('style');
77
+ s.id = 'dc-ai-banner-css';
78
+ s.textContent = BANNER_CSS;
79
+ document.head.appendChild(s);
80
+ }
81
+
82
+ interface AiEntry {
83
+ file: string;
84
+ author: string;
85
+ startedAt: number;
86
+ lastHeartbeat: number;
87
+ }
88
+
89
+ export function AiBanner(): JSX.Element | null {
90
+ ensureBannerStyles();
91
+ const [entry, setEntry] = useState<AiEntry | null>(null);
92
+ const file = deriveFile();
93
+
94
+ // Mount-time backfill — the bus only emits on changes, so a freshly opened
95
+ // tab needs to GET the snapshot to learn that an edit is already in flight.
96
+ useEffect(() => {
97
+ if (!file) return;
98
+ let cancelled = false;
99
+ fetch('/_api/ai')
100
+ .then((r) => r.json())
101
+ .then((j: { entries?: AiEntry[] }) => {
102
+ if (cancelled) return;
103
+ const hit = (j.entries ?? []).find((e) => e.file === file);
104
+ if (hit) setEntry(hit);
105
+ })
106
+ .catch(() => {
107
+ /* offline / restart — silently no-op */
108
+ });
109
+ return () => {
110
+ cancelled = true;
111
+ };
112
+ }, [file]);
113
+
114
+ // Subscribe to the parent shell's WS broadcast — when the inspector channel
115
+ // receives an `ai-activity` event for OUR file path, update local state.
116
+ // The shell relays via postMessage so we don't need our own WS connection
117
+ // (the iframe already piggybacks on the parent's inspector session).
118
+ //
119
+ // FALLBACK — when the canvas is opened standalone (export iframe, _shell.html
120
+ // direct URL), no parent WS exists. We open a thin reader-only inspector WS
121
+ // ourselves and watch for the matching event.
122
+ useEffect(() => {
123
+ if (!file) return;
124
+ if (typeof window === 'undefined') return;
125
+
126
+ // postMessage relay path — parent sends `ai-activity` events.
127
+ const onMessage = (e: MessageEvent) => {
128
+ const m = e.data as { dgn?: string; file?: string; entry?: AiEntry | null } | null;
129
+ if (!m || typeof m !== 'object') return;
130
+ if (m.dgn !== 'ai-activity') return;
131
+ if (m.file !== file) return;
132
+ setEntry(m.entry ?? null);
133
+ };
134
+ window.addEventListener('message', onMessage);
135
+
136
+ // Standalone fallback — if no parent is relaying, dial /_ws directly.
137
+ let standaloneWs: WebSocket | null = null;
138
+ let reconnect: ReturnType<typeof setTimeout> | null = null;
139
+ function connectStandalone() {
140
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
141
+ const ws = new WebSocket(`${proto}//${location.host}/_ws`);
142
+ standaloneWs = ws;
143
+ ws.addEventListener('message', (ev) => {
144
+ try {
145
+ const data = typeof ev.data === 'string' ? ev.data : '';
146
+ if (!data) return;
147
+ const m = JSON.parse(data) as { type?: string; file?: string; entry?: AiEntry | null };
148
+ if (m.type !== 'ai-activity') return;
149
+ if (m.file !== file) return;
150
+ setEntry(m.entry ?? null);
151
+ } catch {
152
+ /* not a JSON frame (binary from collab WS) — ignore */
153
+ }
154
+ });
155
+ ws.addEventListener('close', () => {
156
+ standaloneWs = null;
157
+ reconnect = setTimeout(connectStandalone, 2000);
158
+ });
159
+ ws.addEventListener('error', () => {
160
+ /* close handler reconnects */
161
+ });
162
+ }
163
+ // Only open the standalone WS when we're not inside an iframe — the parent
164
+ // already drives the postMessage relay for embedded canvases.
165
+ if (window.parent === window) connectStandalone();
166
+
167
+ return () => {
168
+ window.removeEventListener('message', onMessage);
169
+ if (reconnect) clearTimeout(reconnect);
170
+ if (standaloneWs && standaloneWs.readyState === WebSocket.OPEN) {
171
+ try {
172
+ standaloneWs.close();
173
+ } catch {
174
+ /* ignore */
175
+ }
176
+ }
177
+ };
178
+ }, [file]);
179
+
180
+ if (!entry) return null;
181
+ return (
182
+ // biome-ignore lint/a11y/useSemanticElements: <output> is a form-result element; role="status" on a div is the canonical live-region banner pattern (APG).
183
+ <div className="dc-ai-banner" role="status" aria-live="polite">
184
+ <span className="dc-ai-banner__dot" aria-hidden="true" />
185
+ <span>{entry.author} is editing this canvas — your changes may conflict</span>
186
+ </div>
187
+ );
188
+ }
@@ -73,7 +73,7 @@ const TOOLBAR_CSS = `
73
73
  appearance: none;
74
74
  }
75
75
  .dc-annot-ctx-sw[aria-pressed="true"] {
76
- box-shadow: 0 0 0 2px var(--accent, #d63b1f);
76
+ box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
77
77
  border-color: transparent;
78
78
  }
79
79
  .dc-annot-ctx-fill--none {
@@ -103,8 +103,8 @@ const TOOLBAR_CSS = `
103
103
  cursor: pointer;
104
104
  }
105
105
  .dc-annot-ctx-btn[aria-pressed="true"] {
106
- background: var(--accent, #d63b1f);
107
- color: var(--accent-fg, #fff);
106
+ background: var(--maude-hud-accent, #d63b1f);
107
+ color: var(--maude-hud-accent-fg, #fff);
108
108
  border-color: transparent;
109
109
  }
110
110
  .dc-annot-ctx-btn:hover { background: rgba(0,0,0,0.04); }
@@ -134,8 +134,8 @@ const TOOLBAR_CSS = `
134
134
  line-height: 1;
135
135
  }
136
136
  .dc-annot-ctx-mode-btn[aria-pressed="true"] {
137
- background: color-mix(in oklab, var(--accent, #d63b1f) 14%, transparent);
138
- color: var(--accent, #d63b1f);
137
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
138
+ color: var(--maude-hud-accent, #d63b1f);
139
139
  font-weight: 600;
140
140
  }
141
141
  .dc-annot-ctx-mode-btn:not([aria-pressed="true"]):hover {
@@ -40,6 +40,7 @@ import { crossedDragThreshold } from './input-router.tsx';
40
40
  import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
41
41
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
42
42
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
43
+ import { useCollab } from './use-collab.tsx';
43
44
  import { useSelectionSetOptional } from './use-selection-set.tsx';
44
45
  import { useToolMode } from './use-tool-mode.tsx';
45
46
  import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
@@ -483,11 +484,11 @@ const ANNOT_CSS = `
483
484
  appearance: none;
484
485
  }
485
486
  .dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
486
- box-shadow: 0 0 0 2px var(--accent, #d63b1f);
487
+ box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
487
488
  border-color: transparent;
488
489
  }
489
490
  .dc-annot-chrome .dc-annot-sw:focus-visible {
490
- outline: 2px solid var(--accent, #d63b1f);
491
+ outline: 2px solid var(--maude-hud-accent, #d63b1f);
491
492
  outline-offset: 2px;
492
493
  }
493
494
  .dc-annot-chrome .dc-annot-sep {
@@ -516,7 +517,7 @@ const ANNOT_CSS = `
516
517
  linear-gradient(135deg, transparent 47%, #d63b1f 47%, #d63b1f 53%, transparent 53%);
517
518
  }
518
519
  .dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
519
- box-shadow: 0 0 0 2px var(--accent, #d63b1f);
520
+ box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
520
521
  border-color: transparent;
521
522
  }
522
523
  .dc-annot-chrome .dc-annot-btn {
@@ -532,13 +533,13 @@ const ANNOT_CSS = `
532
533
  text-transform: uppercase;
533
534
  }
534
535
  .dc-annot-chrome .dc-annot-btn[aria-pressed="true"] {
535
- background: var(--accent, #d63b1f);
536
- color: var(--accent-fg, #fff);
536
+ background: var(--maude-hud-accent, #d63b1f);
537
+ color: var(--maude-hud-accent-fg, #fff);
537
538
  border-color: transparent;
538
539
  }
539
540
  .dc-annot-chrome .dc-annot-btn:hover { background: rgba(0,0,0,0.04); }
540
541
  .dc-annot-chrome .dc-annot-btn:focus-visible {
541
- outline: 2px solid var(--accent, #d63b1f);
542
+ outline: 2px solid var(--maude-hud-accent, #d63b1f);
542
543
  outline-offset: 2px;
543
544
  }
544
545
  .dc-annot-input {
@@ -567,8 +568,8 @@ const ANNOT_CSS = `
567
568
  /* Drag-select marquee — rendered while user is dragging to select strokes. */
568
569
  .dc-annot-marquee {
569
570
  pointer-events: none;
570
- fill: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
571
- stroke: var(--accent, #d63b1f);
571
+ fill: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
572
+ stroke: var(--maude-hud-accent, #d63b1f);
572
573
  stroke-width: 1;
573
574
  stroke-dasharray: 4 3;
574
575
  }
@@ -689,6 +690,10 @@ export function AnnotationsLayer() {
689
690
  const supportsFill = tool === 'rect' || tool === 'ellipse';
690
691
 
691
692
  // Load existing annotations on mount.
693
+ // Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
694
+ // collab connects shortly after this fetch lands) doesn't re-apply the
695
+ // same content we just hydrated from REST.
696
+ const lastAppliedSvgRef = useRef<string>('');
692
697
  useEffect(() => {
693
698
  const file = deriveFile();
694
699
  fileRef.current = file;
@@ -701,7 +706,10 @@ export function AnnotationsLayer() {
701
706
  .then((text) => {
702
707
  if (cancelled) return;
703
708
  const loaded = svgToStrokes(text);
704
- if (loaded.length) setStrokesState(loaded);
709
+ if (loaded.length) {
710
+ setStrokesState(loaded);
711
+ lastAppliedSvgRef.current = text;
712
+ }
705
713
  })
706
714
  .catch(() => {
707
715
  /* network blip — start with an empty annotation set */
@@ -711,6 +719,34 @@ export function AnnotationsLayer() {
711
719
  };
712
720
  }, []);
713
721
 
722
+ // Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
723
+ // other tabs. Bail when the incoming SVG STRING is identical to the one
724
+ // we last applied (covers the local echo round-trip without missing real
725
+ // foreign changes). The prior length+first/last-id check was wrong: a
726
+ // resize / move keeps the same id list, so all three predicates matched
727
+ // even though geometry changed — foreign edits silently disappeared.
728
+ const collab = useCollab();
729
+ useEffect(() => {
730
+ if (!collab) return;
731
+ const map = collab.doc.getMap<string>('annotations');
732
+ const apply = () => {
733
+ const svg = map.get('svg');
734
+ if (typeof svg !== 'string' || !svg) return;
735
+ if (svg === lastAppliedSvgRef.current) return;
736
+ lastAppliedSvgRef.current = svg;
737
+ setStrokesState(svgToStrokes(svg));
738
+ };
739
+ apply();
740
+ map.observe(apply);
741
+ return () => {
742
+ try {
743
+ map.unobserve(apply);
744
+ } catch {
745
+ /* doc destroyed before unmount */
746
+ }
747
+ };
748
+ }, [collab]);
749
+
714
750
  const undoStack = useUndoStackOptional();
715
751
  const undoSinks = useUndoSinks();
716
752
  const undoStackRef = useRef(undoStack);
@@ -734,6 +770,10 @@ export function AnnotationsLayer() {
734
770
  const file = fileRef.current;
735
771
  if (!file) return Promise.resolve();
736
772
  const svg = strokesToSvg(next);
773
+ // Phase 8 Task 5 — record the SVG we just authored locally so the
774
+ // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
775
+ // Y.Map.observe) doesn't trigger a redundant setStrokesState.
776
+ lastAppliedSvgRef.current = svg;
737
777
  return fetch('/_api/annotations', {
738
778
  method: 'PUT',
739
779
  headers: { 'Content-Type': 'application/json' },
@@ -1683,7 +1723,7 @@ function SelectionHalo({
1683
1723
  width={bbox.w + pad * 2}
1684
1724
  height={bbox.h + pad * 2}
1685
1725
  fill="none"
1686
- stroke="var(--accent, #d63b1f)"
1726
+ stroke="var(--maude-hud-accent, #d63b1f)"
1687
1727
  strokeWidth={multi ? 1.5 : 2}
1688
1728
  vectorEffect="non-scaling-stroke"
1689
1729
  pointerEvents="none"
@@ -1738,7 +1778,7 @@ function AnnotGroupBbox({
1738
1778
  width={w}
1739
1779
  height={h}
1740
1780
  fill="none"
1741
- stroke="var(--accent, #d63b1f)"
1781
+ stroke="var(--maude-hud-accent, #d63b1f)"
1742
1782
  strokeWidth={1}
1743
1783
  strokeDasharray="4 3"
1744
1784
  vectorEffect="non-scaling-stroke"
@@ -1751,7 +1791,7 @@ function AnnotGroupBbox({
1751
1791
  y={c.y}
1752
1792
  width={handle}
1753
1793
  height={handle}
1754
- fill="var(--accent, #d63b1f)"
1794
+ fill="var(--maude-hud-accent, #d63b1f)"
1755
1795
  stroke="var(--bg-0, #ffffff)"
1756
1796
  strokeWidth={1}
1757
1797
  vectorEffect="non-scaling-stroke"