@1agh/maude 0.27.0 → 0.28.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 (27) hide show
  1. package/cli/commands/design-link.test.mjs +46 -0
  2. package/cli/commands/doctor.mjs +110 -5
  3. package/cli/commands/doctor.test.mjs +52 -0
  4. package/cli/lib/design-link.mjs +38 -8
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/activity.ts +256 -0
  7. package/plugins/design/dev-server/ai-banner.tsx +4 -0
  8. package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
  9. package/plugins/design/dev-server/canvas-comment-mount.tsx +164 -9
  10. package/plugins/design/dev-server/canvas-lib.tsx +62 -15
  11. package/plugins/design/dev-server/config.schema.json +2 -1
  12. package/plugins/design/dev-server/dist/client.bundle.js +18 -18
  13. package/plugins/design/dev-server/dist/comment-mount.js +82 -4
  14. package/plugins/design/dev-server/inspect.ts +31 -1
  15. package/plugins/design/dev-server/participants-chrome.tsx +86 -1
  16. package/plugins/design/dev-server/server.ts +10 -1
  17. package/plugins/design/dev-server/sync/index.ts +24 -19
  18. package/plugins/design/dev-server/test/activity.test.ts +195 -0
  19. package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
  20. package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
  21. package/plugins/design/dev-server/test/sync-runtime.test.ts +34 -6
  22. package/plugins/design/dev-server/test/use-agent-presence.test.tsx +114 -0
  23. package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
  24. package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
  25. package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
  26. package/plugins/design/dev-server/ws.ts +21 -2
  27. package/plugins/design/templates/_shell.html +108 -3
@@ -230,7 +230,37 @@ const INSPECTOR_SCRIPT = `
230
230
  '.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
231
231
  '.dgn-pin:hover { filter: brightness(1.1); outline: 2px solid rgba(0,0,0,0.3); }',
232
232
  '.dgn-pin.resolved { background: #22c55e; color: #052e16; }',
233
- '.dgn-pin.focused { box-shadow: 0 4px 12px rgba(0,0,0,0.6), 0 0 0 2px #fff; outline: 2px solid #fff; }'
233
+ '.dgn-pin.focused { box-shadow: 0 4px 12px rgba(0,0,0,0.6), 0 0 0 2px #fff; outline: 2px solid #fff; }',
234
+ /* Phase 13 / DDR-029 — canvas activity overlay. Single injection point so the
235
+ canvas iframe stays self-contained. Scoped with html ... so canvas-page
236
+ stylesheets can't clobber it. The "editing" motion is a light scan beam
237
+ (Phase 13.3) sweeping the whole artboard top→bottom — transform-only
238
+ (compositor), unmounts when the file goes idle (infinite-with-control,
239
+ flow:motion-rules §5); reduced-motion drops the beam to a static rim. The
240
+ rim border + glow are static boundary chrome. */
241
+ 'html .dc-activity-rim { position: absolute; pointer-events: none; box-sizing: border-box; border: 2.5px solid var(--mdcc-activity, hsl(210 90% 58%)); border-radius: 5px; z-index: 6; opacity: 1; transition: opacity 200ms ease-out; }',
242
+ 'html .dc-activity-rim[data-fading="true"] { opacity: 0; }',
243
+ 'html .dc-activity-rim::after { content: ""; position: absolute; inset: -1px; border-radius: 6px; box-shadow: 0 0 0 1px var(--mdcc-activity, hsl(210 90% 58%)), 0 0 16px 2px var(--mdcc-activity, hsl(210 90% 58%)); opacity: 0.55; pointer-events: none; will-change: opacity; animation: dc-activity-glow var(--mdcc-activity-scan-ms, 2200ms) ease-in-out infinite; }',
244
+ 'html .dc-activity-badge { position: absolute; top: 4px; right: 4px; z-index: 2; padding: 2px 8px; border-radius: 4px; background: var(--mdcc-activity, hsl(210 90% 58%)); color: #fff; font: 600 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; box-shadow: 0 1px 4px rgba(0,0,0,0.45); }',
245
+ /* Phase 13.3 — the AGENT BORDER is the primary "editing" indicator: a clear
246
+ 2.5px border in the agent color with a softly pulsing glow (::after,
247
+ opacity-only, compositor). Clipping lives on the wash (not the rim) so the
248
+ outward glow is not clipped. The dc-activity-scan wash is ONE full-artboard
249
+ wave: a single gradient with a SHARP bottom edge (full agent color) fading
250
+ up to transparent at the top (height = artboard). Its sharp edge enters at
251
+ the top and the whole wave travels straight down and off past the bottom
252
+ edge (translateY -100% → 100%), then the next wave enters from the top in a
253
+ loop. transform-only; linear so it flows at a steady pace. */
254
+ 'html .dc-activity-scan { position: absolute; inset: 0; overflow: hidden; border-radius: 4px; pointer-events: none; z-index: 1; }',
255
+ 'html .dc-activity-scan::after { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 135%; will-change: transform; background: linear-gradient(to top, color-mix(in oklab, var(--mdcc-activity, hsl(210 90% 58%)) 20%, transparent) 0%, transparent 100%); animation: dc-activity-wave var(--mdcc-activity-scan-ms, 3800ms) linear infinite; }',
256
+ '@keyframes dc-activity-glow { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.85; } }',
257
+ '@keyframes dc-activity-wave { from { transform: translateY(-100%); } to { transform: translateY(100%); } }',
258
+ '@media (prefers-reduced-motion: reduce) { html .dc-activity-rim { transition: none; } html .dc-activity-rim::after { animation: none; opacity: 0.55; } html .dc-activity-scan { display: none; } }',
259
+ /* Phase 13.1 / DDR-077 — "holding last good render" toast, shown when an
260
+ agent edit produced a broken intermediate (build/render error) and the
261
+ canvas is held instead of flashing white. Amber = warn, distinct from the
262
+ blue activity rim. pointer-events:none so it never blocks the canvas. */
263
+ 'html .dc-hmr-holding { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 2147483646; pointer-events: none; max-width: 90vw; padding: 6px 12px; border-radius: 6px; background: hsl(38 92% 50% / 0.96); color: #1c1207; font: 600 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 2px 10px rgba(0,0,0,0.45); }'
234
264
  ].join('\\n');
235
265
  document.documentElement.appendChild(styleEl);
236
266
 
@@ -17,6 +17,7 @@
17
17
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
18
 
19
19
  import { useViewportControllerContext } from './canvas-lib.tsx';
20
+ import { type AgentPresence, useAgentPresence } from './use-agent-presence.tsx';
20
21
  import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
21
22
 
22
23
  const CHROME_CSS = `
@@ -110,6 +111,34 @@ const CHROME_CSS = `
110
111
  @media (prefers-reduced-motion: reduce) {
111
112
  .dc-participant { transition: none; }
112
113
  }
114
+ /* Phase 13.2 / DDR-078 — agent presence avatar. Looks like a peer chip + a
115
+ subtle ✦ marker so an agent reads as distinct from a human collaborator. */
116
+ .dc-participant--agent { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04); }
117
+ .dc-participant-agent-marker {
118
+ position: absolute;
119
+ right: -3px;
120
+ bottom: -3px;
121
+ width: 13px;
122
+ height: 13px;
123
+ border-radius: 50%;
124
+ background: var(--maude-chrome-bg-0, #fff);
125
+ color: var(--maude-chrome-fg-0, #111);
126
+ display: inline-flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ font-size: 8px;
130
+ line-height: 1;
131
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.14);
132
+ pointer-events: none;
133
+ }
134
+ .dc-participant-popover__sub {
135
+ font-size: 11px;
136
+ color: var(--maude-chrome-fg-2, #666);
137
+ margin-top: -4px;
138
+ margin-bottom: 6px;
139
+ white-space: normal;
140
+ max-width: 200px;
141
+ }
113
142
  `.trim();
114
143
 
115
144
  function ensureChromeStyles(): void {
@@ -192,10 +221,63 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
192
221
  );
193
222
  }
194
223
 
224
+ // Phase 13.2 / DDR-078 — an agent rendered as a presence peer. No "Follow"
225
+ // (an agent has no viewport to mirror); a ✦ marker + an "AI agent" popover
226
+ // subtitle distinguish it from a human collaborator.
227
+ function AgentAvatar({ agent }: { agent: AgentPresence }): JSX.Element {
228
+ const [open, setOpen] = useState(false);
229
+ const rootRef = useRef<HTMLDivElement | null>(null);
230
+
231
+ useEffect(() => {
232
+ if (!open) return;
233
+ const onDocClick = (e: MouseEvent) => {
234
+ if (!rootRef.current) return;
235
+ if (!rootRef.current.contains(e.target as Node)) setOpen(false);
236
+ };
237
+ document.addEventListener('mousedown', onDocClick);
238
+ return () => document.removeEventListener('mousedown', onDocClick);
239
+ }, [open]);
240
+
241
+ return (
242
+ <div
243
+ ref={rootRef}
244
+ className="dc-participant dc-participant--agent"
245
+ style={{ background: agent.color }}
246
+ onClick={() => setOpen((v) => !v)}
247
+ onKeyDown={(e) => {
248
+ if (e.key === 'Enter' || e.key === ' ') {
249
+ e.preventDefault();
250
+ setOpen((v) => !v);
251
+ }
252
+ }}
253
+ // biome-ignore lint/a11y/useSemanticElements: matches the peer Avatar chip pattern.
254
+ role="button"
255
+ tabIndex={0}
256
+ title={`${agent.name} (AI agent)`}
257
+ aria-label={`${agent.name}, AI agent`}
258
+ aria-expanded={open}
259
+ >
260
+ {initialsFor(agent.name)}
261
+ <span className="dc-participant-agent-marker" aria-hidden>
262
+
263
+ </span>
264
+ {open && (
265
+ // biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper; no actionable child.
266
+ // biome-ignore lint/a11y/useSemanticElements: popover wrapper carries no semantic role.
267
+ <div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
268
+ <div className="dc-participant-popover__name">{agent.name}</div>
269
+ <div className="dc-participant-popover__sub">AI agent · {agent.author}</div>
270
+ </div>
271
+ )}
272
+ </div>
273
+ );
274
+ }
275
+
195
276
  export function ParticipantsChrome(): JSX.Element | null {
196
277
  ensureChromeStyles();
197
278
  const collab = useCollab();
198
279
  const peers = useForeignAwareness();
280
+ const agent = useAgentPresence();
199
281
  const controller = useViewportControllerContext();
200
282
 
201
283
  const [followTarget, setFollowTarget] = useState<number | null>(null);
@@ -242,7 +324,9 @@ export function ParticipantsChrome(): JSX.Element | null {
242
324
  controller.setViewport(v);
243
325
  }, [controller, followTarget, peers]);
244
326
 
245
- if (peers.length === 0) return null;
327
+ // Render whenever there's a human peer OR an active agent (so an agent shows
328
+ // even with no human collaborators connected).
329
+ if (peers.length === 0 && !agent) return null;
246
330
 
247
331
  return (
248
332
  <div className="dc-participants" aria-label="Active collaborators">
@@ -254,6 +338,7 @@ export function ParticipantsChrome(): JSX.Element | null {
254
338
  onToggleFollow={onToggleFollow}
255
339
  />
256
340
  ))}
341
+ {agent ? <AgentAvatar key={agent.id} agent={agent} /> : null}
257
342
  </div>
258
343
  );
259
344
  }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { spawn } from 'node:child_process';
18
18
 
19
+ import { createActivity } from './activity.ts';
19
20
  import { createApi } from './api.ts';
20
21
  import { bootSelfHeal } from './boot-self-heal.ts';
21
22
  import { createAiActivity } from './collab/ai-activity.ts';
@@ -77,7 +78,10 @@ await inspect.load();
77
78
  collab = createCollab(ctx, api);
78
79
  const aiActivity = createAiActivity(ctx);
79
80
  const gitLifecycle = createGitLifecycle(ctx, collab.registry);
80
- const ws = createWs(ctx, api, inspect, collab);
81
+ // Phase 13 / DDR-029 fs-watch-driven canvas activity overlay. Subscribes to
82
+ // `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.
83
+ const activity = createActivity(ctx);
84
+ const ws = createWs(ctx, api, inspect, collab, activity);
81
85
  const http = createHttp(ctx, api, inspect, aiActivity);
82
86
  const fsWatch = createFsWatch(ctx);
83
87
 
@@ -346,6 +350,11 @@ if (!process.env.NO_OPEN) {
346
350
  async function shutdown() {
347
351
  console.log('\n Stopping…');
348
352
  fsWatch.stop();
353
+ try {
354
+ activity.stop();
355
+ } catch {
356
+ /* timer cleanup is best-effort */
357
+ }
349
358
  try {
350
359
  if (syncRuntime) await syncRuntime.stop();
351
360
  } catch {
@@ -246,16 +246,17 @@ export function createSyncRuntime(
246
246
  return;
247
247
  }
248
248
 
249
- // DDR-072loud boot banner when the project-level TSX opt-in is on against
250
- // a NON-loopback hub. Broadening from hand-picked canvases to "all .tsx"
251
- // also broadens the WebRTC/self-nav exfil residual (the sandbox contains
252
- // execution but not that lane) to every synced canvas, so this must never be
253
- // silent: a sneaky PR that flips `linkedHub.syncTsx` surfaces here on the
254
- // next `serve`. Loopback hubs (local dev) skip it — no remote exfil concern.
255
- if (linkedHub.syncTsx === true && !isLoopbackHubUrl(linkedHub.url)) {
256
- const tsxCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
249
+ // DDR-079TSX sync defaults ON, so every linked non-loopback project that
250
+ // ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
251
+ // contains execution but not that lane) to every synced canvas. The default
252
+ // traded a footgun (silent 0-syncable) for this surface, so the surface must
253
+ // be LOUD: a banner on every `serve` naming the count + the opt-outs. Fires
254
+ // unless explicitly opted out (`syncTsx: false`); loopback hubs (local dev)
255
+ // skip it no remote exfil concern.
256
+ const tsxBodyCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
257
+ if (linkedHub.syncTsx !== false && tsxBodyCount > 0 && !isLoopbackHubUrl(linkedHub.url)) {
257
258
  console.warn(
258
- `[sync] syncTsx=true — ${tsxCount} TSX canvas BODIES will sync to ${linkedHub.url} (project-level opt-in, DDR-072). The sandbox contains execution, but the WebRTC/self-nav exfil residual now applies to ALL synced canvases — only for hubs you operate or fully trust. Set a canvas .meta.json "syncable": false to exclude it.`
259
+ `[sync] ${tsxBodyCount} TSX canvas BODIES will sync to ${linkedHub.url} (TSX sync is ON by default — DDR-079). The sandbox contains execution, but a WebRTC/self-nav exfil residual applies to every synced canvaslink only hubs you operate or trust. Opt out: linkedHub.syncTsx=false (whole project) or a canvas .meta.json "syncable": false (one canvas).`
259
260
  );
260
261
  }
261
262
 
@@ -612,12 +613,15 @@ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
612
613
  // `.tsx` syncs — the per-canvas opt-in is inert without the sandbox, and
613
614
  // decoupling them would re-open the CRITICAL F1 RCE (DDR-060, DDR-054 §F1).
614
615
  const splitActive = !!ctx.canvasOrigin;
615
- // DDR-072 — project-level TSX opt-in. When `linkedHub.syncTsx` is true, a
616
- // `.tsx` with no explicit sidecar verdict defaults to syncable (the per-canvas
617
- // sidecar still wins either way see resolveSyncable). Coupled with the
618
- // sandbox via `splitActive` exactly like the per-canvas opt-in, so the flag is
619
- // inert without the F1 containment (DDR-060 Lock-2 invariant).
620
- const projectSyncTsx = ctx.cfg.linkedHub?.syncTsx === true;
616
+ // DDR-079 (supersedes DDR-072) — TSX sync defaults ON for a linked project.
617
+ // Absence of the flag = ON: a freshly-linked peer sees the project's TSX
618
+ // without a hidden per-project opt-in (the recurring "I linked but my teammate
619
+ // sees nothing" footgun). `linkedHub.syncTsx: false` is the explicit
620
+ // project-wide opt-out; a per-canvas `.meta.json "syncable": false` still wins
621
+ // for one canvas (see resolveSyncable). The Lock-2 sandbox coupling
622
+ // (`splitActive`) is UNTOUCHED — a `.tsx` still syncs ONLY when the cross-origin
623
+ // containment is active; decoupling them would re-open the F1 RCE (DDR-060).
624
+ const projectSyncTsx = ctx.cfg.linkedHub?.syncTsx !== false;
621
625
  for (const group of ctx.cfg.canvasGroups) {
622
626
  const groupAbs = path.join(ctx.paths.designRoot, group.path);
623
627
  if (!existsSync(groupAbs)) continue;
@@ -636,7 +640,7 @@ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
636
640
  }
637
641
 
638
642
  /**
639
- * T3 (9.1-B) + DDR-072 — resolve whether a `.tsx` body is syncable.
643
+ * T3 (9.1-B) + DDR-079 (was DDR-072) — resolve whether a `.tsx` body is syncable.
640
644
  *
641
645
  * Tri-state precedence:
642
646
  * 1. The sibling `<name>.meta.json` `"syncable"` boolean ALWAYS wins when
@@ -644,9 +648,10 @@ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
644
648
  * sidecar; deliberately NOT in the untrusted `/_api/canvas-meta` PATCH
645
649
  * whitelist (api.ts), so a hostile canvas/hub cannot flip its own body into
646
650
  * or out of the sync set.
647
- * 2. Otherwise fall back to the project-level `linkedHub.syncTsx` default.
651
+ * 2. Otherwise fall back to `projectSyncTsx` — which now defaults to TRUE
652
+ * (DDR-079); `linkedHub.syncTsx: false` is the explicit project-wide opt-out.
648
653
  *
649
- * Missing sidecar / parse error → no explicit verdict → the project default.
654
+ * Missing sidecar / parse error → no explicit verdict → the project default (on).
650
655
  */
651
656
  function resolveSyncable(bodyAbs: string, projectSyncTsx: boolean): boolean {
652
657
  const metaAbs = bodyAbs.replace(/\.(tsx|html)$/i, '.meta.json');
@@ -752,7 +757,7 @@ export function buildNoSyncablePayload(
752
757
  ): NoSyncablePayload {
753
758
  const reason =
754
759
  tsxCount > 0
755
- ? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs when the canvas opts in (.meta.json "syncable": true) OR the project opts in all of them (.design/config.json linkedHub.syncTsx: true, DDR-072). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
760
+ ? `${tsxCount} TSX canvas(es) found but none are syncable. TSX sync is ON by default (DDR-079), so this means it was opted OUT either project-wide (.design/config.json linkedHub.syncTsx: false) or per canvas (.meta.json "syncable": false) — OR the cross-origin sandbox is off (MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it, and TSX sync with it — DDR-060). Remove the opt-out / re-enable the sandbox to sync.`
756
761
  : `no canvases found under ${designRoot}.`;
757
762
  return {
758
763
  linked: true,
@@ -0,0 +1,195 @@
1
+ // activity — Phase 13. fs:any → per-file active|idle tracker + region diff.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import {
6
+ ACTIVITY_IDLE_MS,
7
+ type ActivityChange,
8
+ createActivity,
9
+ diffArtboardIds,
10
+ isCanvasFile,
11
+ } from '../activity.ts';
12
+ import { type Context, createBus } from '../context.ts';
13
+
14
+ function mkCtx(): Context {
15
+ const bus = createBus();
16
+ return {
17
+ cfg: {} as Context['cfg'],
18
+ projectLabel: '',
19
+ // No designRoot → refineArtboards() short-circuits; transition tests stay
20
+ // fs-free. The diff is exercised directly via diffArtboardIds() below.
21
+ paths: {} as Context['paths'],
22
+ bus,
23
+ };
24
+ }
25
+
26
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
27
+
28
+ describe('activity / isCanvasFile', () => {
29
+ test('accepts .tsx + .html canvases under ui/', () => {
30
+ expect(isCanvasFile('ui/Smoke TSX.tsx')).toBe(true);
31
+ expect(isCanvasFile('ui/legacy.html')).toBe(true);
32
+ expect(isCanvasFile('ui/components/Card.tsx')).toBe(true);
33
+ });
34
+
35
+ test('rejects non-canvas extensions', () => {
36
+ expect(isCanvasFile('ui/Smoke.css')).toBe(false);
37
+ expect(isCanvasFile('_locator.json')).toBe(false);
38
+ expect(isCanvasFile('ui/shot.png')).toBe(false);
39
+ });
40
+
41
+ test('rejects _-prefixed files + dirs (runtime artifacts, history, draw, smoke)', () => {
42
+ expect(isCanvasFile('_active.json')).toBe(false);
43
+ expect(isCanvasFile('_history/ui/Smoke TSX/0001.tsx')).toBe(false);
44
+ expect(isCanvasFile('_draw/logo.proof.tsx')).toBe(false);
45
+ expect(isCanvasFile('_history/snap.html')).toBe(false);
46
+ });
47
+
48
+ test('rejects SKIP_DIRS segments', () => {
49
+ expect(isCanvasFile('node_modules/react/index.tsx')).toBe(false);
50
+ expect(isCanvasFile('dist/runtime/x.tsx')).toBe(false);
51
+ expect(isCanvasFile('ui/build/out.tsx')).toBe(false);
52
+ });
53
+
54
+ test('rejects DS preview specimens (false-positive guard)', () => {
55
+ expect(isCanvasFile('system/aurora/preview/motion.html')).toBe(false);
56
+ expect(isCanvasFile('system/aurora/preview/motion.tsx')).toBe(false);
57
+ // but a real canvas elsewhere under system/ is fine
58
+ expect(isCanvasFile('system/aurora/showcase.tsx')).toBe(true);
59
+ });
60
+
61
+ test('normalizes back-slash + leading slash', () => {
62
+ expect(isCanvasFile('\\ui\\Smoke.tsx')).toBe(true);
63
+ expect(isCanvasFile('/ui/Smoke.tsx')).toBe(true);
64
+ expect(isCanvasFile('')).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe('activity / transitions', () => {
69
+ test('idle → active on first canvas fs:any event', () => {
70
+ const ctx = mkCtx();
71
+ const seen: ActivityChange[] = [];
72
+ ctx.bus.on('activity:change', (c) => seen.push(c));
73
+ const act = createActivity(ctx, { idleMs: 40, diff: false });
74
+
75
+ ctx.bus.emit('fs:any', 'ui/Smoke.tsx');
76
+ expect(act.state['ui/Smoke.tsx']?.status).toBe('active');
77
+ expect(seen).toHaveLength(1);
78
+ expect(seen[0]).toMatchObject({ file: 'ui/Smoke.tsx', status: 'active', artboard_ids: null });
79
+ act.stop();
80
+ });
81
+
82
+ test('non-canvas fs:any events are ignored', () => {
83
+ const ctx = mkCtx();
84
+ const seen: ActivityChange[] = [];
85
+ ctx.bus.on('activity:change', (c) => seen.push(c));
86
+ const act = createActivity(ctx, { idleMs: 40, diff: false });
87
+
88
+ ctx.bus.emit('fs:any', 'ui/Smoke.css');
89
+ ctx.bus.emit('fs:any', '_active.json');
90
+ ctx.bus.emit('fs:any', 'system/x/preview/motion.tsx');
91
+ expect(seen).toHaveLength(0);
92
+ expect(act.state['ui/Smoke.css']).toBeUndefined();
93
+ act.stop();
94
+ });
95
+
96
+ test('active → active: rapid events keep the file active past one idle window', async () => {
97
+ const ctx = mkCtx();
98
+ const act = createActivity(ctx, { idleMs: 60, diff: false });
99
+
100
+ ctx.bus.emit('fs:any', 'ui/Smoke.tsx');
101
+ await sleep(40);
102
+ ctx.bus.emit('fs:any', 'ui/Smoke.tsx'); // refresh before the 60 ms idle fires
103
+ await sleep(40); // 80 ms since first event, but only 40 ms since the refresh
104
+ expect(act.state['ui/Smoke.tsx']?.status).toBe('active');
105
+ act.stop();
106
+ });
107
+
108
+ test('active → idle after idleMs of silence, and emits the idle change', async () => {
109
+ const ctx = mkCtx();
110
+ const seen: ActivityChange[] = [];
111
+ ctx.bus.on('activity:change', (c) => seen.push(c));
112
+ const act = createActivity(ctx, { idleMs: 40, diff: false });
113
+
114
+ ctx.bus.emit('fs:any', 'ui/Smoke.tsx');
115
+ await sleep(80);
116
+ expect(act.state['ui/Smoke.tsx']?.status).toBe('idle');
117
+ const last = seen[seen.length - 1];
118
+ expect(last).toMatchObject({ file: 'ui/Smoke.tsx', status: 'idle' });
119
+ act.stop();
120
+ });
121
+
122
+ test('mark() test seam marks active without the bus', () => {
123
+ const ctx = mkCtx();
124
+ const seen: ActivityChange[] = [];
125
+ ctx.bus.on('activity:change', (c) => seen.push(c));
126
+ const act = createActivity(ctx, { idleMs: 40, diff: false });
127
+
128
+ act.mark('ui/Manual.tsx');
129
+ expect(act.state['ui/Manual.tsx']?.status).toBe('active');
130
+ expect(seen[0]?.file).toBe('ui/Manual.tsx');
131
+ act.stop();
132
+ });
133
+
134
+ test('stop() halts the idle timer (no idle emit after stop)', async () => {
135
+ const ctx = mkCtx();
136
+ const seen: ActivityChange[] = [];
137
+ ctx.bus.on('activity:change', (c) => seen.push(c));
138
+ const act = createActivity(ctx, { idleMs: 40, diff: false });
139
+
140
+ ctx.bus.emit('fs:any', 'ui/Smoke.tsx');
141
+ act.stop();
142
+ await sleep(80);
143
+ expect(seen.filter((c) => c.status === 'idle')).toHaveLength(0);
144
+ // stop() also detaches the bus subscription.
145
+ ctx.bus.emit('fs:any', 'ui/Other.tsx');
146
+ expect(act.state['ui/Other.tsx']).toBeUndefined();
147
+ });
148
+ });
149
+
150
+ describe('activity / diffArtboardIds (Task 7)', () => {
151
+ const wrap = (primaryBody: string, secondaryBody: string) =>
152
+ `import { DesignCanvas, DCArtboard } from '@maude/canvas-lib';
153
+ export default function C() {
154
+ return (
155
+ <DesignCanvas>
156
+ <DCArtboard id="primary" label="Primary" width={400} height={300}>${primaryBody}</DCArtboard>
157
+ <DCArtboard id="secondary" label="Secondary" width={400} height={300}>${secondaryBody}</DCArtboard>
158
+ </DesignCanvas>
159
+ );
160
+ }`;
161
+
162
+ test('change confined to one artboard → that id only', () => {
163
+ const prev = wrap('<p>A</p>', '<p>B</p>');
164
+ const next = wrap('<p>A</p>', '<p>B changed</p>');
165
+ expect(diffArtboardIds(prev, next)).toEqual(['secondary']);
166
+ });
167
+
168
+ test('change in both artboards → both ids', () => {
169
+ const prev = wrap('<p>A</p>', '<p>B</p>');
170
+ const next = wrap('<p>A2</p>', '<p>B2</p>');
171
+ expect(diffArtboardIds(prev, next)?.sort()).toEqual(['primary', 'secondary']);
172
+ });
173
+
174
+ test('identical content → null (no region changed)', () => {
175
+ const src = wrap('<p>A</p>', '<p>B</p>');
176
+ expect(diffArtboardIds(src, src)).toBeNull();
177
+ });
178
+
179
+ test('change outside any artboard body → null (file-level fallback)', () => {
180
+ const prev = wrap('<p>A</p>', '<p>B</p>');
181
+ // Added an import line outside any DCArtboard → skeleton differs.
182
+ const next = `import x from 'y';\n${wrap('<p>A</p>', '<p>B</p>')}`;
183
+ expect(diffArtboardIds(prev, next)).toBeNull();
184
+ });
185
+
186
+ test('no DCArtboard markers → null (ambiguous)', () => {
187
+ expect(diffArtboardIds('<div>old</div>', '<div>new</div>')).toBeNull();
188
+ });
189
+
190
+ test('artboard added/removed → null (shell differs)', () => {
191
+ const prev = wrap('<p>A</p>', '<p>B</p>');
192
+ const next = `${wrap('<p>A</p>', '<p>B</p>')}\n// trailing <DCArtboard id="third" label="T" width={1} height={1}>x</DCArtboard>`;
193
+ expect(diffArtboardIds(prev, next)).toBeNull();
194
+ });
195
+ });
@@ -0,0 +1,56 @@
1
+ // artboard-activity-overlay — Phase 13. Presentational overlay contract.
2
+ //
3
+ // The positioned overlay only renders inside a laid-out DesignCanvas (rect
4
+ // resolves after layout effects, which renderToStaticMarkup doesn't run), so we
5
+ // assert the leaf component directly: the rim box, the badge text, and the
6
+ // active→fading toggle. The gating decision (present + artboard scope) is
7
+ // covered in use-canvas-activity.test.tsx.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { renderToStaticMarkup } from 'react-dom/server';
12
+
13
+ import { ArtboardActivityOverlay } from '../artboard-activity-overlay.tsx';
14
+
15
+ const RECT = { x: 10, y: 20, w: 300, h: 200 };
16
+
17
+ describe('artboard-activity-overlay', () => {
18
+ test('renders the rim + badge with the editing label', () => {
19
+ const html = renderToStaticMarkup(
20
+ <ArtboardActivityOverlay rect={RECT} label="Smoke TSX.tsx" active />
21
+ );
22
+ expect(html).toContain('dc-activity-rim');
23
+ expect(html).toContain('dc-activity-badge');
24
+ expect(html).toContain('dc-activity-scan'); // Phase 13.3 full-artboard scan beam
25
+ expect(html).toContain('editing —');
26
+ expect(html).toContain('Smoke TSX.tsx');
27
+ expect(html).toContain('aria-hidden');
28
+ });
29
+
30
+ test('agent color overrides the activity CSS var', () => {
31
+ const html = renderToStaticMarkup(
32
+ <ArtboardActivityOverlay rect={RECT} label="Nimble Ferret" active color="#ff9800" />
33
+ );
34
+ expect(html).toMatch(/--mdcc-activity:\s*#ff9800/);
35
+ });
36
+
37
+ test('active overlay is NOT marked fading', () => {
38
+ const html = renderToStaticMarkup(<ArtboardActivityOverlay rect={RECT} label="x" active />);
39
+ expect(html).not.toContain('data-fading');
40
+ });
41
+
42
+ test('idle overlay carries data-fading for the cross-fade out', () => {
43
+ const html = renderToStaticMarkup(
44
+ <ArtboardActivityOverlay rect={RECT} label="x" active={false} />
45
+ );
46
+ expect(html).toContain('data-fading="true"');
47
+ });
48
+
49
+ test('positions at the artboard world rect', () => {
50
+ const html = renderToStaticMarkup(<ArtboardActivityOverlay rect={RECT} label="x" active />);
51
+ expect(html).toMatch(/left:\s*10px/);
52
+ expect(html).toMatch(/top:\s*20px/);
53
+ expect(html).toMatch(/width:\s*300px/);
54
+ expect(html).toMatch(/height:\s*200px/);
55
+ });
56
+ });
@@ -0,0 +1,114 @@
1
+ // canvas-hmr-runtime — Phase 13.1 / DDR-077. The resettable error boundary that
2
+ // keeps the last good render (no white flash) when an agent edit produces a
3
+ // broken intermediate, and recovers when a good build lands.
4
+ //
5
+ // Error boundaries don't run under renderToStaticMarkup (SSR rethrows), so this
6
+ // needs a live DOM. We register happy-dom for THIS file only (like
7
+ // annotations-roundtrip.test.ts) and drive a real createRoot.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
10
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
11
+
12
+ beforeAll(() => {
13
+ GlobalRegistrator.register();
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+ });
16
+ afterAll(() => {
17
+ GlobalRegistrator.unregister();
18
+ });
19
+
20
+ import { act, createElement } from 'react';
21
+ import { type Root, createRoot } from 'react-dom/client';
22
+
23
+ import { CanvasErrorBoundary } from '../canvas-comment-mount.tsx';
24
+
25
+ function Good({ tag }: { tag: string }) {
26
+ return createElement('div', { 'data-good': tag }, `good:${tag}`);
27
+ }
28
+ function Boom(): never {
29
+ throw new Error('render exploded (missing import)');
30
+ }
31
+
32
+ function mount(el: HTMLElement) {
33
+ let root!: Root;
34
+ act(() => {
35
+ root = createRoot(el);
36
+ });
37
+ return root;
38
+ }
39
+
40
+ describe('CanvasErrorBoundary', () => {
41
+ test('renders children when they do not throw', () => {
42
+ const host = document.createElement('div');
43
+ const root = mount(host);
44
+ act(() => {
45
+ root.render(
46
+ createElement(
47
+ CanvasErrorBoundary,
48
+ {
49
+ attempt: 0,
50
+ onError: () => {},
51
+ fallback: () => createElement(Good, { tag: 'fallback' }),
52
+ },
53
+ createElement(Good, { tag: 'live' })
54
+ )
55
+ );
56
+ });
57
+ expect(host.querySelector('[data-good="live"]')).not.toBeNull();
58
+ expect(host.querySelector('[data-good="fallback"]')).toBeNull();
59
+ act(() => root.unmount());
60
+ });
61
+
62
+ test('a throwing child → renders fallback (NOT blank) and fires onError once', () => {
63
+ const host = document.createElement('div');
64
+ const root = mount(host);
65
+ let errors = 0;
66
+ act(() => {
67
+ root.render(
68
+ createElement(
69
+ CanvasErrorBoundary,
70
+ {
71
+ attempt: 0,
72
+ onError: () => {
73
+ errors++;
74
+ },
75
+ fallback: () => createElement(Good, { tag: 'lastgood' }),
76
+ },
77
+ createElement(Boom)
78
+ )
79
+ );
80
+ });
81
+ // The boundary held a render (the fallback) — the host is NOT empty.
82
+ expect(host.querySelector('[data-good="lastgood"]')).not.toBeNull();
83
+ expect(host.textContent).toContain('good:lastgood');
84
+ expect(errors).toBe(1);
85
+ act(() => root.unmount());
86
+ });
87
+
88
+ test('a new attempt with a good child recovers (swaps fallback → live)', () => {
89
+ const host = document.createElement('div');
90
+ const root = mount(host);
91
+ const render = (attempt: number, child: ReturnType<typeof createElement>) =>
92
+ act(() => {
93
+ root.render(
94
+ createElement(
95
+ CanvasErrorBoundary,
96
+ {
97
+ attempt,
98
+ onError: () => {},
99
+ fallback: () => createElement(Good, { tag: 'lastgood' }),
100
+ },
101
+ child
102
+ )
103
+ );
104
+ });
105
+
106
+ render(0, createElement(Boom)); // broken build → fallback
107
+ expect(host.querySelector('[data-good="lastgood"]')).not.toBeNull();
108
+
109
+ render(1, createElement(Good, { tag: 'fixed' })); // good build lands
110
+ expect(host.querySelector('[data-good="fixed"]')).not.toBeNull();
111
+ expect(host.querySelector('[data-good="lastgood"]')).toBeNull();
112
+ act(() => root.unmount());
113
+ });
114
+ });