@1agh/maude 0.23.0 → 0.25.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 (127) 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/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -33,6 +33,17 @@ export type Target =
33
33
  file: string;
34
34
  /** True when `cssPath` is expected to match many elements; the adapter iterates. */
35
35
  multi?: boolean;
36
+ /**
37
+ * True when the adapter should widen `cssPath` to its closest
38
+ * `[data-dc-screen]` ancestor before capture — i.e. "export the artboard
39
+ * containing this element". `selection` scope sets this `false` so the
40
+ * raster/vector captures the element EXACTLY (the item-3 bug was every
41
+ * single-target export hard-widening). `artboard` scope sets it `true`
42
+ * only in the fallback where we have a descendant selector but no
43
+ * artboard id; when the id IS known we target `[data-dc-screen="<id>"]`
44
+ * directly and leave this `false`.
45
+ */
46
+ widen?: boolean;
36
47
  }
37
48
  | {
38
49
  kind: 'file-tree';
@@ -49,6 +60,21 @@ export interface ActiveJsonShape {
49
60
  | null;
50
61
  }
51
62
 
63
+ /**
64
+ * Submit-time hints captured from the LIVE iframe (the export dialog snapshots
65
+ * the canvas selection + the artboard under the viewport centre and rides them
66
+ * on the export `options` bag). The resolver prefers these over `activeJson`
67
+ * because opening the dialog / clicking Export can drop the persisted
68
+ * `_active.json.selected`, and they survive the cross-origin bridge unchanged.
69
+ * See the export-pipeline-fixes plan Task 1 + the selection-passthrough DDR.
70
+ */
71
+ export interface ExportScopeHints {
72
+ /** Live selection at submit time. `selector` wins over `activeJson.selected`. */
73
+ selection?: { selector?: string; file?: string } | null;
74
+ /** `data-dc-screen` id of the artboard to export for `scope=artboard`. */
75
+ artboardId?: string | null;
76
+ }
77
+
52
78
  export interface ResolveScopeArgs {
53
79
  scope: Scope;
54
80
  activeJson: ActiveJsonShape;
@@ -56,6 +82,33 @@ export interface ResolveScopeArgs {
56
82
  designRoot: string;
57
83
  /** Absolute path to repo root. Required for `project-raw` to bound the walk. */
58
84
  repoRoot?: string;
85
+ /**
86
+ * The export `options` bag, threaded from `runExport`. Read additively so
87
+ * existing callers (and the pure unit tests) that omit it stay green.
88
+ */
89
+ options?: Record<string, unknown>;
90
+ }
91
+
92
+ /** Narrow the free-form options bag to the selection/artboard hints we read. */
93
+ function readHints(options: Record<string, unknown> | undefined): ExportScopeHints {
94
+ if (!options || typeof options !== 'object') return {};
95
+ const sel = options.selection;
96
+ const selection =
97
+ sel && typeof sel === 'object'
98
+ ? {
99
+ selector:
100
+ typeof (sel as Record<string, unknown>).selector === 'string'
101
+ ? ((sel as Record<string, unknown>).selector as string)
102
+ : undefined,
103
+ file:
104
+ typeof (sel as Record<string, unknown>).file === 'string'
105
+ ? ((sel as Record<string, unknown>).file as string)
106
+ : undefined,
107
+ }
108
+ : null;
109
+ const artboardId =
110
+ typeof options.artboardId === 'string' && options.artboardId ? options.artboardId : null;
111
+ return { selection, artboardId };
59
112
  }
60
113
 
61
114
  const RAW_EXCLUDES = new Set([
@@ -88,6 +141,16 @@ function slugify(file: string, designRel: string): string {
88
141
  .toLowerCase();
89
142
  }
90
143
 
144
+ /**
145
+ * Escape a value for use inside a double-quoted CSS attribute selector
146
+ * (`[data-dc-screen="<here>"]`). Artboard ids are normally plain slugs, but a
147
+ * stray `"` or `\` would break out of the selector — escape defensively so the
148
+ * id is matched literally.
149
+ */
150
+ function cssAttrEscape(value: string): string {
151
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
152
+ }
153
+
91
154
  interface SelectionShape {
92
155
  file?: string;
93
156
  selector?: string;
@@ -147,37 +210,70 @@ export async function resolveScope(args: ResolveScopeArgs): Promise<Target[]> {
147
210
  if (!activeFile) return [];
148
211
  const slug = slugify(activeFile, designRel);
149
212
  const sel = firstSelection(activeJson.selected);
213
+ const hints = readHints(args.options);
150
214
 
151
215
  if (scope === 'selection') {
152
- const selector = sel?.selector ?? sel?.cssPath;
153
- if (!sel || !selector) {
154
- // Plan: "Falls back to artboard if no selection." Recurse with the
155
- // artboard scope so the fallback semantics live in one place.
216
+ // Prefer the live submit-time snapshot over the persisted `_active.json`
217
+ // selection the dialog can clear the canvas selection before the bridged
218
+ // export runs, leaving `activeJson.selected` null (item 3 root cause #1).
219
+ const selector = hints.selection?.selector ?? sel?.selector ?? sel?.cssPath;
220
+ if (!selector) {
221
+ // No selection anywhere → fall back to artboard scope so the export
222
+ // still produces something useful. Single fallback site.
156
223
  return resolveScope({ ...args, scope: 'artboard' });
157
224
  }
158
- const file = sel.file ?? activeFile;
225
+ const file = hints.selection?.file ?? sel?.file ?? activeFile;
159
226
  return [
160
227
  {
161
228
  kind: 'element',
162
229
  cssPath: selector,
163
230
  canvasSlug: slugify(file, designRel),
164
231
  file,
232
+ // Capture the element EXACTLY — do NOT widen to the enclosing artboard
233
+ // (item 3 root cause #2: every single-target export hard-widened).
234
+ widen: false,
165
235
  },
166
236
  ];
167
237
  }
168
238
 
169
239
  if (scope === 'artboard') {
170
- // The adapter handles "closest [data-dc-screen] ancestor" at render time
171
- // via Playwright. Server-side we only know the selection's selector we
172
- // pass it through with a marker and the adapter widens to the artboard.
173
- // If no selection, fall back to "first artboard on the active canvas".
174
- const baseSelector = sel?.selector ?? sel?.cssPath ?? '[data-dc-screen]:first-of-type';
240
+ // Preferred path: the dialog captured which artboard is active (the
241
+ // selection's host, or the artboard under the viewport centre). Target it
242
+ // by id so EVERY format (PDF included its shim doesn't widen) renders
243
+ // the right artboard, not `:first-of-type` (item 5 root cause).
244
+ if (hints.artboardId) {
245
+ return [
246
+ {
247
+ kind: 'element',
248
+ cssPath: `[data-dc-screen="${cssAttrEscape(hints.artboardId)}"]`,
249
+ canvasSlug: slug,
250
+ file: activeFile,
251
+ widen: false,
252
+ },
253
+ ];
254
+ }
255
+ // Fallback: we only have a descendant selector → pass it through and let
256
+ // the adapter widen to the closest `[data-dc-screen]` ancestor.
257
+ const descendant = hints.selection?.selector ?? sel?.selector ?? sel?.cssPath;
258
+ if (descendant) {
259
+ return [
260
+ {
261
+ kind: 'element',
262
+ cssPath: descendant,
263
+ canvasSlug: slug,
264
+ file: activeFile,
265
+ widen: true,
266
+ },
267
+ ];
268
+ }
269
+ // Last resort — first artboard on the active canvas.
175
270
  return [
176
271
  {
177
272
  kind: 'element',
178
- cssPath: baseSelector,
273
+ cssPath: '[data-dc-screen]:first-of-type',
179
274
  canvasSlug: slug,
180
275
  file: activeFile,
276
+ widen: false,
181
277
  },
182
278
  ];
183
279
  }
@@ -44,7 +44,10 @@ async function captureSvg(
44
44
  if (target.multi) {
45
45
  args.push('--multi', '1', '--out-dir', outDir);
46
46
  } else {
47
- args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.svg`));
47
+ // Widen to the enclosing artboard only when scope.ts requested it; a
48
+ // `selection` target captures the element exactly.
49
+ if (target.widen) args.push('--widen-to-artboard', '1');
50
+ args.push('--out', path.join(outDir, `${target.canvasSlug}.svg`));
48
51
  }
49
52
 
50
53
  // Run via `node` so the shim's `import 'playwright'` resolves against
@@ -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,80 @@ 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
+ // Per-process boot id, folded into every canvas ETag. The canvas transpile
157
+ // BUNDLES the shared chrome (canvas-lib → tool-palette / annotations-layer /
158
+ // use-tool-mode / canvas-cursors / …), but the freshness sig + base etag only
159
+ // track the canvas file + its inlined CSS — NOT those chrome sources. So a
160
+ // chrome edit left the etag unchanged → the browser's `If-None-Match` matched →
161
+ // 304 → the user kept getting the STALE transpile even after a server restart
162
+ // (the recurring "my cursor / chrome change didn't show up" bug). Folding the
163
+ // boot id in means a restart — the expected workflow after a dev-server source
164
+ // edit (see CLAUDE.md) — changes every canvas etag, so a normal reload re-fetches
165
+ // fresh chrome. (DDR-067.)
166
+ const RUNTIME_BOOT_ID = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
167
+
168
+ // Bumped by the chrome source watchers (below) on every canvas-lib / dev-server
169
+ // `.tsx`/`.ts` edit. Folded into the canvas ETag so a LIVE chrome edit (no
170
+ // restart) also changes the etag — otherwise clearing `canvasCache` re-transpiles
171
+ // fresh on the server, but the unchanged canvas SOURCE yields the same
172
+ // `result.etag`, the browser's `If-None-Match` matches → 304 → the user still
173
+ // gets the stale bundle even after the HMR hard-reload. (DDR-067.)
174
+ let CHROME_EPOCH = 0;
175
+
176
+ /** Relative `.css` import specifiers in a canvas source → absolute paths (the
177
+ * files Bun.build inlines). Bare / virtual specifiers (`@maude/…`, npm) are
178
+ * skipped — only on-disk relative CSS affects the inlined output. Resolved
179
+ * paths are clamped to `designRoot`: a canvas source is attacker-influenced
180
+ * under linked mode (DDR-054), and these paths are `stat`-ed for mtime, so a
181
+ * `../../../etc/…` specifier must not let the freshness probe reach outside the
182
+ * design tree. Legit DS imports (`../../system/<ds>/…`) stay inside designRoot. */
183
+ function cssDepsFromSource(source: string, canvasAbsPath: string, designRoot: string): string[] {
184
+ const dir = dirname(canvasAbsPath);
185
+ const root = resolve(designRoot);
186
+ const deps: string[] = [];
187
+ const re = /import\s+["']([^"']+\.css)["']/g;
188
+ let m: RegExpExecArray | null = re.exec(source);
189
+ while (m !== null) {
190
+ if (m[1].startsWith('.')) {
191
+ const abs = resolve(dir, m[1]);
192
+ if (abs === root || abs.startsWith(root + sep)) deps.push(abs);
193
+ }
194
+ m = re.exec(source);
195
+ }
196
+ return deps;
197
+ }
198
+
199
+ /** mtime signature over the .tsx + its inlined CSS deps. A missing/unreadable
200
+ * file contributes 0 — a delete is itself a change, so the signature differs. */
201
+ function canvasFreshnessSig(tsxAbsPath: string, cssDeps: string[]): string {
202
+ const parts: string[] = [];
203
+ for (const p of [tsxAbsPath, ...cssDeps]) {
204
+ const mt = Bun.file(p).lastModified;
205
+ parts.push(`${p}@${Number.isFinite(mt) ? mt : 0}`);
206
+ }
207
+ return parts.join('|');
208
+ }
209
+
84
210
  async function serveCanvasTsx(
85
211
  absPath: string,
86
212
  req: Request,
@@ -90,12 +216,16 @@ async function serveCanvasTsx(
90
216
  const file = Bun.file(absPath);
91
217
  if (!(await file.exists())) return new Response('Not found', { status: 404 });
92
218
 
93
- // `stat` via Bun.file().lastModified falls back to 0 if unavailable.
94
- const mtimeMs = typeof file.lastModified === 'number' ? file.lastModified : 0;
219
+ // Freshness = the .tsx mtime AND every inlined sibling `.css` mtime. Keying on
220
+ // the .tsx alone meant a direct edit to `<canvas>.css` was a cache HIT, so the
221
+ // HMR reload (mode:'module') re-served the stale inlined CSS — the edit only
222
+ // surfaced once the .tsx itself changed (DDR-064 dogfooding finding).
95
223
  let cached = canvasCache.get(absPath);
224
+ const sig = canvasFreshnessSig(absPath, cached?.cssDeps ?? []);
96
225
 
97
- if (!cached || cached.mtimeMs !== mtimeMs) {
226
+ if (!cached || cached.sig !== sig) {
98
227
  const source = await file.text();
228
+ const cssDeps = cssDepsFromSource(source, absPath, ctx.paths.designRoot);
99
229
  let result: Awaited<ReturnType<typeof buildCanvasModule>>;
100
230
  try {
101
231
  result = await buildCanvasModule(absPath, source, {
@@ -114,7 +244,17 @@ async function serveCanvasTsx(
114
244
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
115
245
  });
116
246
  }
117
- cached = { mtimeMs, etag: result.etag, js: result.js };
247
+ // Recompute the signature against the freshly-parsed deps — this very edit
248
+ // may have added or removed a `.css` import.
249
+ cached = {
250
+ sig: canvasFreshnessSig(absPath, cssDeps),
251
+ // Fold in the boot id (restart) + chrome epoch (live edit) so a chrome
252
+ // change busts the browser's cached transpile even when the canvas source
253
+ // (hence result.etag) is unchanged. See RUNTIME_BOOT_ID / CHROME_EPOCH.
254
+ etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
255
+ js: result.js,
256
+ cssDeps,
257
+ };
118
258
  canvasCache.set(absPath, cached);
119
259
  // Persist the locator map. Awaited so the inspector / Phase-12 layers
120
260
  // panel sees a consistent (cdId -> source) view by the time the canvas
@@ -156,6 +296,22 @@ async function serveFile(absPath: string, headers: Record<string, string> = {}):
156
296
  export interface Http {
157
297
  routes: Record<string, (req: Request) => Response | Promise<Response>>;
158
298
  fetch(req: Request): Promise<Response>;
299
+ /**
300
+ * T2 (9.1-A) — build the canvas mount-harness response. `applyCsp` adds the
301
+ * strict CSP (always on for the segregated canvas origin; the legacy main
302
+ * origin keeps it env-gated for the POC). Shared so both listeners produce
303
+ * byte-identical HTML.
304
+ */
305
+ serveCanvasShell(applyCsp: boolean): Promise<Response>;
306
+ /**
307
+ * T2 (9.1-A) — allowlist gate for the segregated canvas origin. Returns true
308
+ * only for the routes the canvas runtime legitimately needs (shell, runtime
309
+ * bundles, comment-mount, transpiled .tsx + CSS/assets under designRoot,
310
+ * git-user, canvas-meta, health). Everything else is 403'd at the door so
311
+ * hub-pushed JSX can't reach /_api/export, /_config, /_sync-status, comments,
312
+ * the app shell, or arbitrary repo files. WS upgrades are gated in server.ts.
313
+ */
314
+ isCanvasSafeRoute(pathname: string): boolean;
159
315
  }
160
316
 
161
317
  export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActivity): Http {
@@ -176,6 +332,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
176
332
  ctx.bus.on('fs:any', (rel: string) => {
177
333
  if (rel.startsWith('_lib/')) {
178
334
  canvasCache.clear();
335
+ CHROME_EPOCH++;
179
336
  }
180
337
  });
181
338
 
@@ -183,6 +340,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
183
340
  try {
184
341
  libWatcher = watch(canvasLibPath(), () => {
185
342
  canvasCache.clear();
343
+ CHROME_EPOCH++;
186
344
  ctx.bus.emit('fs:any', '_lib/canvas-lib.tsx');
187
345
  });
188
346
  } catch (err) {
@@ -194,21 +352,27 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
194
352
  void libWatcher;
195
353
 
196
354
  // G7v2 — canvas-lib.tsx transitively imports many dev-server siblings
197
- // (canvas-shell, contextual-toolbar, equal-spacing-handles, ...). Editing
198
- // any of them invalidates the bundled canvas output. Without watching them
199
- // the mtime-keyed `canvasCache` keeps serving the stale bundle and the
200
- // user sees pre-edit behaviour even after a hard iframe reload.
355
+ // (canvas-shell, contextual-toolbar, equal-spacing-handles, tool-palette,
356
+ // use-tool-mode, ...). Editing any of them invalidates the bundled canvas
357
+ // output. Without watching them the mtime-keyed `canvasCache` keeps serving
358
+ // the stale bundle and the user sees pre-edit behaviour even after a hard
359
+ // iframe reload.
201
360
  //
202
- // Recursive watch over DEV_SERVER_ROOT, filtered to .tsx server-only .ts
203
- // (api / http / context / etc.) doesn't reach the canvas. Test files
204
- // (`test/`) and built output (`dist/`, `client/`) also skipped.
361
+ // Recursive watch over DEV_SERVER_ROOT, filtered to .tsx AND .ts: the chrome
362
+ // graph includes plain `.ts` modules that DO reach the canvas
363
+ // (`canvas-cursors.ts`, `canvas-arrowheads.ts`) the prior `.tsx`-only filter
364
+ // skipped them, so cursor edits never busted the cache (the recurring stale-
365
+ // cursor bug, DDR-067). Server-only `.ts` (api / http / context) also clears
366
+ // here, which is harmless (a needless rebuild, not a stale serve). Test files
367
+ // (`test/`), built output (`dist/`, `client/`), and node_modules are skipped.
205
368
  let devSrcWatcher: ReturnType<typeof watch> | null = null;
206
369
  try {
207
370
  devSrcWatcher = watch(DEV_SERVER_ROOT, { recursive: true }, (_evt, filename) => {
208
371
  if (!filename) return;
209
- if (!filename.endsWith('.tsx')) return;
372
+ if (!filename.endsWith('.tsx') && !filename.endsWith('.ts')) return;
210
373
  if (filename.startsWith('test/') || filename.startsWith('test\\')) return;
211
374
  if (filename.startsWith('dist/') || filename.startsWith('client/')) return;
375
+ if (filename.includes('node_modules')) return;
212
376
  canvasCache.clear();
213
377
  ctx.bus.emit('fs:any', `_lib/${filename}`);
214
378
  });
@@ -241,7 +405,24 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
241
405
 
242
406
  '/_active': () => Response.json(inspect.state),
243
407
 
244
- '/_config': () => Response.json(ctx.cfg),
408
+ // Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
409
+ // runtime writes `_sync.json`; browser tabs also get live pushes over the
410
+ // WS ('sync:status'). Returns `{ linked: false }` in solo mode.
411
+ '/_sync-status': () => {
412
+ const file = join(ctx.paths.designRoot, '_sync.json');
413
+ if (!existsSync(file)) {
414
+ return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
415
+ }
416
+ try {
417
+ return Response.json(JSON.parse(readFileSync(file, 'utf8')), {
418
+ headers: { 'Cache-Control': 'no-store' },
419
+ });
420
+ } catch {
421
+ return Response.json({ linked: false }, { headers: { 'Cache-Control': 'no-store' } });
422
+ }
423
+ },
424
+
425
+ '/_config': () => Response.json({ ...ctx.cfg, canvasOrigin: ctx.canvasOrigin }),
245
426
 
246
427
  '/_index-data': async () =>
247
428
  Response.json(await api.buildIndexData(), { headers: { 'Cache-Control': 'no-store' } }),
@@ -585,14 +766,10 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
585
766
  // Query parameter ?canvas=<path-relative-to-designRoot> tells the shell
586
767
  // which canvas to import + mount. See plugins/design/templates/_shell.html.
587
768
  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
- });
769
+ // The segregated canvas origin (server.ts) calls serveCanvasShell(true)
770
+ // directly with CSP always on; on the legacy main origin the CSP stays
771
+ // env-gated (MAUDE_CSP_POC) for the POC / backwards-compat.
772
+ return serveCanvasShell(process.env.MAUDE_CSP_POC === '1');
596
773
  }
597
774
 
598
775
  // Fall-through: serve user repo files (designRoot + everything under repoRoot).
@@ -622,5 +799,99 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
622
799
  }
623
800
  }
624
801
 
625
- return { routes, fetch };
802
+ async function serveCanvasShell(applyCsp: boolean): Promise<Response> {
803
+ const shellHtml = await Bun.file(join(TEMPLATES_DIR, '_shell.html')).text();
804
+ // Inject inspector overlay — Cmd+Click selection + add-comment flow.
805
+ const injected = inspect.injectInspector(shellHtml);
806
+ const headers: Record<string, string> = {
807
+ 'Content-Type': 'text/html; charset=utf-8',
808
+ 'Cache-Control': 'no-store',
809
+ };
810
+ if (applyCsp) headers['Content-Security-Policy'] = cspForCanvasShell(injected, ctx.mainOrigin);
811
+ return new Response(injected, { headers });
812
+ }
813
+
814
+ // Canvas assets the segregated origin may serve out of designRoot. Excludes
815
+ // `.json` so no `*.meta.json` / `config.json` / `_comments/*.json` leaks via
816
+ // the static lane (canvas-meta goes through the gated /_api route instead).
817
+ const CANVAS_ASSET_EXTS = new Set([
818
+ '.tsx',
819
+ '.css',
820
+ '.svg',
821
+ '.png',
822
+ '.jpg',
823
+ '.jpeg',
824
+ '.gif',
825
+ '.webp',
826
+ '.ico',
827
+ '.woff',
828
+ '.woff2',
829
+ '.ttf',
830
+ '.otf',
831
+ ]);
832
+
833
+ // Exact API paths the canvas iframe needs (collab + display data). See
834
+ // isCanvasSafeRoute for the trust rationale. Mutations are limited to inert
835
+ // collab data (annotations SVG, comment replies via the dynamic route).
836
+ const CANVAS_SAFE_API = new Set([
837
+ '/_api/git-user', // presence display name
838
+ '/_api/canvas-meta', // layout/viewport sidecar (GET + PATCH)
839
+ '/_api/annotations', // annotation SVG (GET + PUT) — drives the collab bridge
840
+ '/_api/git-committers', // @mention autocomplete
841
+ '/_api/ai', // AI-activity banner
842
+ '/_comments', // per-file comment list (renders pins)
843
+ ]);
844
+
845
+ function isCanvasSafeRoute(pathname: string): boolean {
846
+ // A1/A2 (DDR-060 F1 re-audit, phase-9.1-t2-f1-cross-origin-reaudit.md) —
847
+ // DECODE + NORMALIZE before gating. `URL.pathname` preserves `%2f` (it does
848
+ // NOT decode it to `/`), so a raw allowlist check on the encoded path is
849
+ // fooled: `/.design/..%2fsite%2fx.css` reads as ONE opaque segment under the
850
+ // designRoot with an asset ext (the `_`-segment + ext checks see no literal
851
+ // slash to split on), yet `safePathUnderRoot` later DECODES the same `%2f`,
852
+ // turns `..%2f` into a real `../`, and climbs out of the designRoot — re-
853
+ // confined only to repoRoot. That decode mismatch let a hub-pushed canvas
854
+ // read any repo `.tsx`/`.css`/`.svg`/font + `_history` snapshots. Decoding
855
+ // here makes the gate agree with the resolver: `..%2f` → `../`, normalize
856
+ // collapses it, and the path no longer matches designPrefix → 403. A
857
+ // malformed escape (`%ZZ`) throws → reject. This gate runs ONLY on the
858
+ // segregated canvas origin (server.ts), so the main origin is untouched.
859
+ let safe: string;
860
+ try {
861
+ safe = posix.normalize(decodeURIComponent(pathname));
862
+ } catch {
863
+ return false;
864
+ }
865
+ if (safe === '/_canvas-shell.html' || safe === '/_canvas-shell') return true;
866
+ if (safe === '/_health') return true;
867
+ if (safe === '/_client/comment-mount.js') return true;
868
+ // Canvas-chrome stylesheets (composer / thread / pin / cursor CSS). Inert
869
+ // static assets from the dev-server distribution — no secrets, no code
870
+ // exec, no repo content. Without this the cross-origin canvas 403s e.g.
871
+ // `/_client/comments-overlay.css`, so the in-iframe comment composer renders
872
+ // unstyled and, missing `position: fixed`, collapses to the top-left (0,0).
873
+ // Allowed by pattern (not per-file) so future chrome CSS can't silently
874
+ // regress the same way.
875
+ if (safe.startsWith('/_client/') && ext(safe) === '.css') return true;
876
+ if (safe.startsWith('/_canvas-runtime/')) return true;
877
+ // Collab + display-data endpoints the canvas runtime legitimately calls from
878
+ // inside the iframe. All are reads or inert collab writes (annotations SVG,
879
+ // comment replies) — the "safe to sync" set per DDR-054. None expose code
880
+ // execution, secrets, export, /_config, /_sync-status, or files outside
881
+ // designRoot/annotations; the canvas origin's CSP `connect-src 'self'` still
882
+ // confines the iframe so hub-pushed JSX can't reach IMDS/LAN/main-origin.
883
+ if (CANVAS_SAFE_API.has(safe)) return true;
884
+ // POST /_api/comments/<id>/reply — dynamic path (fetch-handled).
885
+ if (/^\/_api\/comments\/[A-Za-z0-9_]+\/reply$/.test(safe)) return true;
886
+ const designPrefix = `/${ctx.paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
887
+ if (safe.startsWith(designPrefix)) {
888
+ const rest = safe.slice(designPrefix.length);
889
+ // Reject runtime/state dirs+files (_comments, _sync.json, _history, …).
890
+ if (rest.split('/').some((seg) => seg.startsWith('_'))) return false;
891
+ return CANVAS_ASSET_EXTS.has(ext(safe));
892
+ }
893
+ return false;
894
+ }
895
+
896
+ return { routes, fetch, serveCanvasShell, isCanvasSafeRoute };
626
897
  }