@1agh/maude 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -1210,6 +1210,22 @@
1210
1210
  color: var(--u-fg-1);
1211
1211
  }
1212
1212
 
1213
+ .sb-sync {
1214
+ cursor: help;
1215
+ }
1216
+
1217
+ .sb-sync-dot {
1218
+ background: var(--u-status-warn);
1219
+ border-radius: 50%;
1220
+ flex: none;
1221
+ width: 7px;
1222
+ height: 7px;
1223
+ }
1224
+
1225
+ .sb-sync .sb-key {
1226
+ color: var(--u-fg-2);
1227
+ }
1228
+
1213
1229
  .sb-spacer {
1214
1230
  border-right: 1px solid var(--u-border-subtle);
1215
1231
  flex: 1 0 auto;
@@ -48,7 +48,7 @@ const STYLES = `
48
48
  .dc-cv-eq-pill {
49
49
  position: absolute;
50
50
  transform: translate(-50%, calc(-100% - 8px));
51
- font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
51
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, monospace);
52
52
  font-size: 10px;
53
53
  padding: 2px 5px;
54
54
  background: #FF24BD;
@@ -113,35 +113,35 @@ const ExportDialogContext = createContext<ExportDialogValue | null>(null);
113
113
 
114
114
  const DIALOG_CSS = `
115
115
  .dc-export-dialog {
116
- border: 1px solid var(--u-fg-0, #1c1917);
116
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
117
117
  padding: 0;
118
118
  border-radius: 8px;
119
- background: var(--u-bg-0, var(--bg-0, #fff));
120
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
121
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
122
- color: var(--u-fg-0, var(--fg-0, #1a1a1a));
119
+ background: var(--maude-chrome-bg-0, #fff);
120
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
121
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
122
+ color: var(--maude-chrome-fg-0, #1a1a1a);
123
123
  width: min(640px, 100vw - 48px);
124
124
  max-height: min(560px, 100vh - 48px);
125
125
  overflow: hidden;
126
126
  }
127
127
  .dc-export-dialog::backdrop { background: rgba(20, 20, 30, 0.32); }
128
- .dc-export-dialog header { padding: 16px 20px; border-bottom: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: space-between; align-items: center; }
128
+ .dc-export-dialog header { padding: 16px 20px; border-bottom: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08)); display: flex; justify-content: space-between; align-items: center; }
129
129
  .dc-export-dialog header h2 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.005em; }
130
- .dc-export-dialog header .dc-ed-close { background: transparent; border: 0; cursor: pointer; padding: 4px 8px; color: var(--fg-1, rgba(40,30,20,0.6)); font: inherit; font-size: 12px; }
130
+ .dc-export-dialog header .dc-ed-close { background: transparent; border: 0; cursor: pointer; padding: 4px 8px; color: var(--maude-chrome-fg-1, rgba(40,30,20,0.6)); font: inherit; font-size: 12px; }
131
131
  .dc-export-dialog .dc-ed-body { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px 20px; }
132
- .dc-export-dialog label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-2, rgba(40,30,20,0.5)); margin-bottom: 6px; }
133
- .dc-export-dialog select { width: 100%; padding: 8px 10px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 13px; color: inherit; }
134
- .dc-export-dialog .dc-ed-desc { font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); margin-top: 6px; line-height: 1.4; }
135
- .dc-export-dialog .dc-ed-recent { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); background: var(--bg-2, rgba(0,0,0,0.02)); }
136
- .dc-export-dialog .dc-ed-recent h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-2, rgba(40,30,20,0.5)); margin: 0 0 8px; }
132
+ .dc-export-dialog label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--maude-chrome-fg-1, rgba(40,30,20,0.5)); margin-bottom: 6px; }
133
+ .dc-export-dialog select { width: 100%; padding: 8px 10px; border-radius: 0; border: 1px solid var(--maude-chrome-fg-0, rgba(0,0,0,0.12)); background: var(--maude-chrome-bg-1, #fafafa); font: inherit; font-size: 13px; color: inherit; }
134
+ .dc-export-dialog .dc-ed-desc { font-size: 12px; color: var(--maude-chrome-fg-1, rgba(40,30,20,0.65)); margin-top: 6px; line-height: 1.4; }
135
+ .dc-export-dialog .dc-ed-recent { padding: 12px 20px; border-top: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08)); background: var(--maude-chrome-bg-2, rgba(0,0,0,0.02)); }
136
+ .dc-export-dialog .dc-ed-recent h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--maude-chrome-fg-1, rgba(40,30,20,0.5)); margin: 0 0 8px; }
137
137
  .dc-export-dialog .dc-ed-recent ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; max-height: 120px; overflow-y: auto; }
138
138
  .dc-export-dialog .dc-ed-recent button { display: flex; justify-content: space-between; gap: 12px; padding: 5px 12px; background: transparent; border: 1px solid transparent; border-radius: 0; cursor: pointer; font: inherit; font-size: 12px; color: inherit; width: 100%; text-align: left; }
139
- .dc-export-dialog .dc-ed-recent button:hover { background: rgba(0,0,0,0.04); border-color: rgba(0,0,0,0.06); }
140
- .dc-export-dialog footer { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: flex-end; gap: 8px; }
141
- .dc-export-dialog footer button { padding: 8px 14px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 12px; cursor: pointer; color: inherit; }
139
+ .dc-export-dialog .dc-ed-recent button:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 5%, transparent); border-color: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 8%, transparent); }
140
+ .dc-export-dialog footer { padding: 12px 20px; border-top: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08)); display: flex; justify-content: flex-end; gap: 8px; }
141
+ .dc-export-dialog footer button { padding: 8px 14px; border-radius: 0; border: 1px solid var(--maude-chrome-fg-0, rgba(0,0,0,0.12)); background: var(--maude-chrome-bg-1, #fafafa); font: inherit; font-size: 12px; cursor: pointer; color: inherit; }
142
142
  .dc-export-dialog footer button.dc-ed-primary { background: var(--maude-hud-accent, #1a1a1a); color: var(--maude-hud-accent-fg, #fff); border-color: transparent; }
143
143
  .dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
144
- .dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); }
144
+ .dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--maude-chrome-fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08)); }
145
145
  .dc-export-dialog .dc-ed-status.is-error { color: var(--status-error, #c0392b); }
146
146
  `;
147
147
 
@@ -371,7 +371,9 @@ const DialogShell = (() => {
371
371
  <span>
372
372
  {FORMAT_META[h.format].label} · {SCOPE_META[h.scope].label}
373
373
  </span>
374
- <span style={{ color: 'var(--fg-2, rgba(40,30,20,0.5))' }}>{h.filename}</span>
374
+ <span style={{ color: 'var(--maude-chrome-fg-1, rgba(40,30,20,0.5))' }}>
375
+ {h.filename}
376
+ </span>
375
377
  </button>
376
378
  </li>
377
379
  ))}
@@ -40,6 +40,7 @@ export function createFsWatch(ctx: Context): FsWatch {
40
40
  // Skip our own runtime artifacts.
41
41
  if (filename.startsWith('_server.json')) return;
42
42
  if (filename.startsWith('_active.json')) return;
43
+ if (filename.startsWith('_sync.json')) return;
43
44
  if (filename.startsWith(`_history${path.sep}`) || filename.startsWith('_history/')) return;
44
45
  emit(filename);
45
46
  });
@@ -4,8 +4,9 @@
4
4
  // the route table without rewriting this module. The `fetch` export is the
5
5
  // top-level fall-through for paths Bun's `routes` field doesn't cover.
6
6
 
7
- import { watch } from 'node:fs';
8
- import { join, posix } from 'node:path';
7
+ import { createHash } from 'node:crypto';
8
+ import { existsSync, readFileSync, watch } from 'node:fs';
9
+ import { dirname, join, posix, resolve, sep } from 'node:path';
9
10
 
10
11
  import type { Api } from './api.ts';
11
12
  import { buildCanvasModule } from './canvas-build.ts';
@@ -52,6 +53,67 @@ function ext(p: string): string {
52
53
  return i === -1 ? '' : p.slice(i).toLowerCase();
53
54
  }
54
55
 
56
+ /**
57
+ * T2 (9.1-A) — build the strict CSP for the canvas-content shell. Every inline
58
+ * `<script>` (importmap, module bootstrap, inspector) is allowlisted by sha256
59
+ * hash so we never resort to `'unsafe-inline'`. `connect-src 'self'` is locked
60
+ * to the document's own origin so hub-pushed JSX can't beacon out / hit IMDS /
61
+ * LAN — and `'self'` covers same-origin `ws:`/`wss:` (CSP3), so the HMR + collab
62
+ * sockets (which connect to the canvas origin the iframe loads from) still work
63
+ * while `ws://attacker` / `wss://attacker` exfil is refused. `style-src
64
+ * 'unsafe-inline'` is intentional —
65
+ * specimens use `style={{…}}` attributes + injected `<style>`; style injection
66
+ * is not the F1 RCE vector (script + connect are). No `'unsafe-eval'` — the
67
+ * POC verifies the runtime (motion/pixi/Bun.build output) doesn't need it.
68
+ *
69
+ * `webrtc 'block'` (A6, DDR-060 F1 re-audit) — `connect-src` governs only
70
+ * fetch/XHR/WebSocket/sendBeacon; WebRTC does NOT flow through Fetch, so an
71
+ * `RTCPeerConnection` with an attacker STUN/TURN hostname smuggles bytes out via
72
+ * ICE DNS/STUN even under `connect-src 'self'`. ⚠️ This directive is specified
73
+ * (CSP3) but UNIMPLEMENTED in shipping Chrome/Firefox as of 2026 (Chromium
74
+ * #40188662, Firefox bug 1783489) — currently a NO-OP. The enforceable control
75
+ * today is the RTC-constructor lockout in `templates/_shell.html`; this directive
76
+ * is kept for when browsers honor it. WebRTC + self-navigation exfil remain
77
+ * DOCUMENTED residuals of opt-in linked mode (the reachable data is collab
78
+ * metadata, not repo files — traversal is closed). Do NOT treat this as a closed
79
+ * exfil lane. The canvas runtime itself uses zero WebRTC.
80
+ *
81
+ * `frame-ancestors` (A6) — restricts who may embed the canvas document. The
82
+ * legit embedder is the main dev-server origin, so we allowlist exactly that
83
+ * (`mainOrigin`) plus `'self'`; an arbitrary external page can no longer reframe
84
+ * the canvas. When `mainOrigin` is unknown (tests / pre-boot) the directive is
85
+ * OMITTED rather than set to `'self'` — `'self'` alone would forbid the legit
86
+ * cross-origin embed and blank the canvas.
87
+ */
88
+ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
89
+ const hashes: string[] = [];
90
+ // Match inline <script> blocks only (no src=). `[^>]*` excludes any with src.
91
+ const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
92
+ let m: RegExpExecArray | null;
93
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop.
94
+ while ((m = re.exec(html)) !== null) {
95
+ const body = m[1] ?? '';
96
+ const digest = createHash('sha256').update(body, 'utf8').digest('base64');
97
+ hashes.push(`'sha256-${digest}'`);
98
+ }
99
+ const scriptSrc = ["'self'", ...hashes].join(' ');
100
+ const directives = [
101
+ "default-src 'none'",
102
+ `script-src ${scriptSrc}`,
103
+ "connect-src 'self'",
104
+ "img-src 'self' data: blob:",
105
+ "style-src 'self' 'unsafe-inline'",
106
+ "font-src 'self' data:",
107
+ "frame-src 'self'",
108
+ "base-uri 'none'",
109
+ "object-src 'none'",
110
+ "form-action 'none'",
111
+ "webrtc 'block'",
112
+ ];
113
+ if (mainOrigin) directives.push(`frame-ancestors 'self' ${mainOrigin}`);
114
+ return directives.join('; ');
115
+ }
116
+
55
117
  function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
56
118
  let pathname: string;
57
119
  try {
@@ -71,16 +133,60 @@ const DIST_DIR = join(HERE, 'dist');
71
133
  const CLIENT_DIR = join(HERE, 'client');
72
134
  const TEMPLATES_DIR = join(HERE, '..', 'templates');
73
135
 
74
- // In-memory transpile cache. Key = absolute canvas path; value = the last
75
- // transpile keyed by mtime. Repeat GETs against an unchanged source skip the
76
- // parse + ID-injection + Bun.Transpiler entirely.
136
+ // In-memory transpile cache. Key = absolute canvas path. Repeat GETs against an
137
+ // unchanged source skip the parse + ID-injection + Bun.Transpiler + Bun.build
138
+ // entirely.
77
139
  interface CanvasCacheEntry {
78
- mtimeMs: number;
140
+ /** Freshness signature over the .tsx AND every directly-imported sibling/
141
+ * relative `.css` it inlines (Bun.build extracts `import "./x.css"` into a
142
+ * `<style>` tag — there is no `<link>`). Rebuild when ANY of them changes.
143
+ * A prior version keyed only on the .tsx mtime, so editing a sibling
144
+ * `<canvas>.css` was a cache HIT: the HMR reload (mode:'module') re-served the
145
+ * stale inlined CSS and the edit only showed once the .tsx itself changed.
146
+ * (DDR-064 collab dogfooding finding, 2026-05-29.) */
147
+ sig: string;
79
148
  etag: string;
80
149
  js: string;
150
+ /** Absolute paths of the relative `.css` imports inlined into this build —
151
+ * re-statted each request to recompute `sig` without re-reading the source. */
152
+ cssDeps: string[];
81
153
  }
82
154
  const canvasCache = new Map<string, CanvasCacheEntry>();
83
155
 
156
+ /** Relative `.css` import specifiers in a canvas source → absolute paths (the
157
+ * files Bun.build inlines). Bare / virtual specifiers (`@maude/…`, npm) are
158
+ * skipped — only on-disk relative CSS affects the inlined output. Resolved
159
+ * paths are clamped to `designRoot`: a canvas source is attacker-influenced
160
+ * under linked mode (DDR-054), and these paths are `stat`-ed for mtime, so a
161
+ * `../../../etc/…` specifier must not let the freshness probe reach outside the
162
+ * design tree. Legit DS imports (`../../system/<ds>/…`) stay inside designRoot. */
163
+ function cssDepsFromSource(source: string, canvasAbsPath: string, designRoot: string): string[] {
164
+ const dir = dirname(canvasAbsPath);
165
+ const root = resolve(designRoot);
166
+ const deps: string[] = [];
167
+ const re = /import\s+["']([^"']+\.css)["']/g;
168
+ let m: RegExpExecArray | null = re.exec(source);
169
+ while (m !== null) {
170
+ if (m[1].startsWith('.')) {
171
+ const abs = resolve(dir, m[1]);
172
+ if (abs === root || abs.startsWith(root + sep)) deps.push(abs);
173
+ }
174
+ m = re.exec(source);
175
+ }
176
+ return deps;
177
+ }
178
+
179
+ /** mtime signature over the .tsx + its inlined CSS deps. A missing/unreadable
180
+ * file contributes 0 — a delete is itself a change, so the signature differs. */
181
+ function canvasFreshnessSig(tsxAbsPath: string, cssDeps: string[]): string {
182
+ const parts: string[] = [];
183
+ for (const p of [tsxAbsPath, ...cssDeps]) {
184
+ const mt = Bun.file(p).lastModified;
185
+ parts.push(`${p}@${Number.isFinite(mt) ? mt : 0}`);
186
+ }
187
+ return parts.join('|');
188
+ }
189
+
84
190
  async function serveCanvasTsx(
85
191
  absPath: string,
86
192
  req: Request,
@@ -90,12 +196,16 @@ async function serveCanvasTsx(
90
196
  const file = Bun.file(absPath);
91
197
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
92
198
 
93
- // `stat` via Bun.file().lastModified falls back to 0 if unavailable.
94
- const mtimeMs = typeof file.lastModified === 'number' ? file.lastModified : 0;
199
+ // Freshness = the .tsx mtime AND every inlined sibling `.css` mtime. Keying on
200
+ // the .tsx alone meant a direct edit to `<canvas>.css` was a cache HIT, so the
201
+ // HMR reload (mode:'module') re-served the stale inlined CSS — the edit only
202
+ // surfaced once the .tsx itself changed (DDR-064 dogfooding finding).
95
203
  let cached = canvasCache.get(absPath);
204
+ const sig = canvasFreshnessSig(absPath, cached?.cssDeps ?? []);
96
205
 
97
- if (!cached || cached.mtimeMs !== mtimeMs) {
206
+ if (!cached || cached.sig !== sig) {
98
207
  const source = await file.text();
208
+ const cssDeps = cssDepsFromSource(source, absPath, ctx.paths.designRoot);
99
209
  let result: Awaited<ReturnType<typeof buildCanvasModule>>;
100
210
  try {
101
211
  result = await buildCanvasModule(absPath, source, {
@@ -114,7 +224,14 @@ async function serveCanvasTsx(
114
224
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
115
225
  });
116
226
  }
117
- cached = { mtimeMs, etag: result.etag, js: result.js };
227
+ // Recompute the signature against the freshly-parsed deps — this very edit
228
+ // may have added or removed a `.css` import.
229
+ cached = {
230
+ sig: canvasFreshnessSig(absPath, cssDeps),
231
+ etag: result.etag,
232
+ js: result.js,
233
+ cssDeps,
234
+ };
118
235
  canvasCache.set(absPath, cached);
119
236
  // Persist the locator map. Awaited so the inspector / Phase-12 layers
120
237
  // panel sees a consistent (cdId -> source) view by the time the canvas
@@ -156,6 +273,22 @@ async function serveFile(absPath: string, headers: Record<string, string> = {}):
156
273
  export interface Http {
157
274
  routes: Record<string, (req: Request) => Response | Promise<Response>>;
158
275
  fetch(req: Request): Promise<Response>;
276
+ /**
277
+ * T2 (9.1-A) — build the canvas mount-harness response. `applyCsp` adds the
278
+ * strict CSP (always on for the segregated canvas origin; the legacy main
279
+ * origin keeps it env-gated for the POC). Shared so both listeners produce
280
+ * byte-identical HTML.
281
+ */
282
+ serveCanvasShell(applyCsp: boolean): Promise<Response>;
283
+ /**
284
+ * T2 (9.1-A) — allowlist gate for the segregated canvas origin. Returns true
285
+ * only for the routes the canvas runtime legitimately needs (shell, runtime
286
+ * bundles, comment-mount, transpiled .tsx + CSS/assets under designRoot,
287
+ * git-user, canvas-meta, health). Everything else is 403'd at the door so
288
+ * hub-pushed JSX can't reach /_api/export, /_config, /_sync-status, comments,
289
+ * the app shell, or arbitrary repo files. WS upgrades are gated in server.ts.
290
+ */
291
+ isCanvasSafeRoute(pathname: string): boolean;
159
292
  }
160
293
 
161
294
  export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActivity): Http {
@@ -241,7 +374,24 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
241
374
 
242
375
  '/_active': () => Response.json(inspect.state),
243
376
 
244
- '/_config': () => Response.json(ctx.cfg),
377
+ // Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
378
+ // runtime writes `_sync.json`; browser tabs also get live pushes over the
379
+ // WS ('sync:status'). Returns `{ linked: false }` in solo mode.
380
+ '/_sync-status': () => {
381
+ const file = join(ctx.paths.designRoot, '_sync.json');
382
+ if (!existsSync(file)) {
383
+ return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
384
+ }
385
+ try {
386
+ return Response.json(JSON.parse(readFileSync(file, 'utf8')), {
387
+ headers: { 'Cache-Control': 'no-store' },
388
+ });
389
+ } catch {
390
+ return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
391
+ }
392
+ },
393
+
394
+ '/_config': () => Response.json({ ...ctx.cfg, canvasOrigin: ctx.canvasOrigin }),
245
395
 
246
396
  '/_index-data': async () =>
247
397
  Response.json(await api.buildIndexData(), { headers: { 'Cache-Control': 'no-store' } }),
@@ -585,14 +735,10 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
585
735
  // Query parameter ?canvas=<path-relative-to-designRoot> tells the shell
586
736
  // which canvas to import + mount. See plugins/design/templates/_shell.html.
587
737
  if (pathname === '/_canvas-shell.html' || pathname === '/_canvas-shell') {
588
- const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
589
- // Inject inspector overlay Cmd+Click selection + Shift/C+Click
590
- // add-comment flow. Without this, TSX canvases mount fine but lose
591
- // every interactive devtool.
592
- const injected = inspect.injectInspector(shellHtml);
593
- return new Response(injected, {
594
- headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
595
- });
738
+ // The segregated canvas origin (server.ts) calls serveCanvasShell(true)
739
+ // directly with CSP always on; on the legacy main origin the CSP stays
740
+ // env-gated (MAUDE_CSP_POC) for the POC / backwards-compat.
741
+ return serveCanvasShell(process.env.MAUDE_CSP_POC === '1');
596
742
  }
597
743
 
598
744
  // Fall-through: serve user repo files (designRoot + everything under repoRoot).
@@ -622,5 +768,99 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
622
768
  }
623
769
  }
624
770
 
625
- return { routes, fetch };
771
+ async function serveCanvasShell(applyCsp: boolean): Promise<Response> {
772
+ const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
773
+ // Inject inspector overlay — Cmd+Click selection + add-comment flow.
774
+ const injected = inspect.injectInspector(shellHtml);
775
+ const headers: Record<string, string> = {
776
+ 'Content-Type': 'text/html; charset=utf-8',
777
+ 'Cache-Control': 'no-store',
778
+ };
779
+ if (applyCsp) headers['Content-Security-Policy'] = cspForCanvasShell(injected, ctx.mainOrigin);
780
+ return new Response(injected, { headers });
781
+ }
782
+
783
+ // Canvas assets the segregated origin may serve out of designRoot. Excludes
784
+ // `.json` so no `*.meta.json` / `config.json` / `_comments/*.json` leaks via
785
+ // the static lane (canvas-meta goes through the gated /_api route instead).
786
+ const CANVAS_ASSET_EXTS = new Set([
787
+ '.tsx',
788
+ '.css',
789
+ '.svg',
790
+ '.png',
791
+ '.jpg',
792
+ '.jpeg',
793
+ '.gif',
794
+ '.webp',
795
+ '.ico',
796
+ '.woff',
797
+ '.woff2',
798
+ '.ttf',
799
+ '.otf',
800
+ ]);
801
+
802
+ // Exact API paths the canvas iframe needs (collab + display data). See
803
+ // isCanvasSafeRoute for the trust rationale. Mutations are limited to inert
804
+ // collab data (annotations SVG, comment replies via the dynamic route).
805
+ const CANVAS_SAFE_API = new Set([
806
+ '/_api/git-user', // presence display name
807
+ '/_api/canvas-meta', // layout/viewport sidecar (GET + PATCH)
808
+ '/_api/annotations', // annotation SVG (GET + PUT) — drives the collab bridge
809
+ '/_api/git-committers', // @mention autocomplete
810
+ '/_api/ai', // AI-activity banner
811
+ '/_comments', // per-file comment list (renders pins)
812
+ ]);
813
+
814
+ function isCanvasSafeRoute(pathname: string): boolean {
815
+ // A1/A2 (DDR-060 F1 re-audit, phase-9.1-t2-f1-cross-origin-reaudit.md) —
816
+ // DECODE + NORMALIZE before gating. `URL.pathname` preserves `%2f` (it does
817
+ // NOT decode it to `/`), so a raw allowlist check on the encoded path is
818
+ // fooled: `/.design/..%2fsite%2fx.css` reads as ONE opaque segment under the
819
+ // designRoot with an asset ext (the `_`-segment + ext checks see no literal
820
+ // slash to split on), yet `safePathUnderRoot` later DECODES the same `%2f`,
821
+ // turns `..%2f` into a real `../`, and climbs out of the designRoot — re-
822
+ // confined only to repoRoot. That decode mismatch let a hub-pushed canvas
823
+ // read any repo `.tsx`/`.css`/`.svg`/font + `_history` snapshots. Decoding
824
+ // here makes the gate agree with the resolver: `..%2f` → `../`, normalize
825
+ // collapses it, and the path no longer matches designPrefix → 403. A
826
+ // malformed escape (`%ZZ`) throws → reject. This gate runs ONLY on the
827
+ // segregated canvas origin (server.ts), so the main origin is untouched.
828
+ let safe: string;
829
+ try {
830
+ safe = posix.normalize(decodeURIComponent(pathname));
831
+ } catch {
832
+ return false;
833
+ }
834
+ if (safe === '/_canvas-shell.html' || safe === '/_canvas-shell') return true;
835
+ if (safe === '/_health') return true;
836
+ if (safe === '/_client/comment-mount.js') return true;
837
+ // Canvas-chrome stylesheets (composer / thread / pin / cursor CSS). Inert
838
+ // static assets from the dev-server distribution — no secrets, no code
839
+ // exec, no repo content. Without this the cross-origin canvas 403s e.g.
840
+ // `/_client/comments-overlay.css`, so the in-iframe comment composer renders
841
+ // unstyled and, missing `position: fixed`, collapses to the top-left (0,0).
842
+ // Allowed by pattern (not per-file) so future chrome CSS can't silently
843
+ // regress the same way.
844
+ if (safe.startsWith('/_client/') && ext(safe) === '.css') return true;
845
+ if (safe.startsWith('/_canvas-runtime/')) return true;
846
+ // Collab + display-data endpoints the canvas runtime legitimately calls from
847
+ // inside the iframe. All are reads or inert collab writes (annotations SVG,
848
+ // comment replies) — the "safe to sync" set per DDR-054. None expose code
849
+ // execution, secrets, export, /_config, /_sync-status, or files outside
850
+ // designRoot/annotations; the canvas origin's CSP `connect-src 'self'` still
851
+ // confines the iframe so hub-pushed JSX can't reach IMDS/LAN/main-origin.
852
+ if (CANVAS_SAFE_API.has(safe)) return true;
853
+ // POST /_api/comments/<id>/reply — dynamic path (fetch-handled).
854
+ if (/^\/_api\/comments\/[A-Za-z0-9_]+\/reply$/.test(safe)) return true;
855
+ const designPrefix = `/${ctx.paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
856
+ if (safe.startsWith(designPrefix)) {
857
+ const rest = safe.slice(designPrefix.length);
858
+ // Reject runtime/state dirs+files (_comments, _sync.json, _history, …).
859
+ if (rest.split('/').some((seg) => seg.startsWith('_'))) return false;
860
+ return CANVAS_ASSET_EXTS.has(ext(safe));
861
+ }
862
+ return false;
863
+ }
864
+
865
+ return { routes, fetch, serveCanvasShell, isCanvasSafeRoute };
626
866
  }
@@ -63,9 +63,27 @@ export function crossedDragThreshold(
63
63
  * returns `no-op` for the corresponding pointer events so the SVG overlay
64
64
  * can grab them natively.
65
65
  */
66
- export type Tool = 'move' | 'hand' | 'comment' | 'pen' | 'rect' | 'ellipse' | 'arrow' | 'eraser';
67
-
68
- const ANNOTATION_TOOLS = new Set<Tool>(['pen', 'rect', 'ellipse', 'arrow', 'eraser']);
66
+ export type Tool =
67
+ | 'move'
68
+ | 'hand'
69
+ | 'comment'
70
+ | 'pen'
71
+ | 'rect'
72
+ | 'ellipse'
73
+ | 'arrow'
74
+ | 'sticky'
75
+ | 'text'
76
+ | 'eraser';
77
+
78
+ const ANNOTATION_TOOLS = new Set<Tool>([
79
+ 'pen',
80
+ 'rect',
81
+ 'ellipse',
82
+ 'arrow',
83
+ 'sticky',
84
+ 'text',
85
+ 'eraser',
86
+ ]);
69
87
 
70
88
  export function isAnnotationTool(t: Tool): boolean {
71
89
  return ANNOTATION_TOOLS.has(t);
@@ -146,6 +164,11 @@ export function classify(input: ClassifyInput): RouterAction {
146
164
  if (k === 'r') return { kind: 'tool', tool: 'rect' };
147
165
  if (k === 'o') return { kind: 'tool', tool: 'ellipse' };
148
166
  if (k === 'a') return { kind: 'tool', tool: 'arrow' };
167
+ // Phase 21 — N = sticky Note ('S' is taken by the shell Design-system view
168
+ // + Shift-marquee); T = standalone Text. Both are bare letters the shell
169
+ // yields when focus is inside the canvas iframe (app.jsx onKey bail).
170
+ if (k === 'n') return { kind: 'tool', tool: 'sticky' };
171
+ if (k === 't') return { kind: 'tool', tool: 'text' };
149
172
  if (k === 'e') return { kind: 'tool', tool: 'eraser' };
150
173
  if (input.key === 'Escape') return { kind: 'escape' };
151
174
  return { kind: 'no-op' };
@@ -42,7 +42,7 @@ const CHROME_CSS = `
42
42
  line-height: 1;
43
43
  letter-spacing: 0.02em;
44
44
  color: var(--maude-hud-accent-fg, #fff);
45
- border: 1.5px solid var(--bg-0, #fff);
45
+ border: 1.5px solid var(--maude-chrome-bg-0, #fff);
46
46
  margin-left: -6px;
47
47
  cursor: pointer;
48
48
  position: relative;
@@ -70,20 +70,20 @@ const CHROME_CSS = `
70
70
  right: 0;
71
71
  min-width: 168px;
72
72
  padding: 10px 12px;
73
- background: var(--bg-1, #fff);
74
- color: var(--fg-0, #111);
75
- border: 1px solid var(--border-default, rgba(0,0,0,0.16));
73
+ background: var(--maude-chrome-bg-1, #fff);
74
+ color: var(--maude-chrome-fg-0, #111);
75
+ border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
76
76
  border-radius: var(--radius-md, 4px);
77
77
  font-size: 12px;
78
78
  line-height: 1.35;
79
79
  white-space: nowrap;
80
- box-shadow: 0 6px 18px rgba(0,0,0,0.10);
80
+ box-shadow: 0 6px 18px var(--maude-chrome-shadow, rgba(0,0,0,0.10));
81
81
  z-index: 11;
82
82
  }
83
83
  .dc-participant-popover__name {
84
84
  font-weight: 600;
85
85
  margin-bottom: 8px;
86
- color: var(--fg-0, #111);
86
+ color: var(--maude-chrome-fg-0, #111);
87
87
  letter-spacing: 0.01em;
88
88
  }
89
89
  .dc-participant-popover__btn {
@@ -102,11 +102,11 @@ const CHROME_CSS = `
102
102
  }
103
103
  .dc-participant-popover__btn:hover { background: var(--maude-hud-accent-hover, var(--maude-hud-accent, oklch(50% 0.170 50))); }
104
104
  .dc-participant-popover__btn--stop {
105
- background: var(--bg-3, #e5e5e5);
106
- color: var(--fg-0, #111);
107
- border: 1px solid var(--border-subtle, rgba(0,0,0,0.12));
105
+ background: color-mix(in oklab, var(--maude-chrome-fg-0, #111) 10%, transparent);
106
+ color: var(--maude-chrome-fg-0, #111);
107
+ border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.12));
108
108
  }
109
- .dc-participant-popover__btn--stop:hover { background: var(--bg-4, #d8d8d8); }
109
+ .dc-participant-popover__btn--stop:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #111) 16%, transparent); }
110
110
  @media (prefers-reduced-motion: reduce) {
111
111
  .dc-participant { transition: none; }
112
112
  }