@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
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @file artboard-activity-overlay.tsx — Phase 13 / DDR-029 overlay.
3
+ * @scope plugins/design/dev-server/artboard-activity-overlay.tsx
4
+ * @purpose The "agent works here" chrome for a single artboard: an animated
5
+ * rim + a top-right `editing — <label>` badge. Rendered by
6
+ * `DCArtboard` as a world-coord sibling of the `<article>`, so it
7
+ * pans/zooms with the artboard for free.
8
+ *
9
+ * Decorative — `aria-hidden` + `pointer-events:none`. All visual styling
10
+ * (border, pulse keyframes, reduced-motion fallback) lives in the inspector
11
+ * style block injected by `inspect.ts` (single injection point — DDR-029), so
12
+ * this component only places the box and toggles `data-fading` for the 200 ms
13
+ * cross-fade out when the file goes idle.
14
+ */
15
+
16
+ import type { CSSProperties } from 'react';
17
+
18
+ export interface OverlayRect {
19
+ x: number;
20
+ y: number;
21
+ w: number;
22
+ h: number;
23
+ }
24
+
25
+ export function ArtboardActivityOverlay({
26
+ rect,
27
+ label,
28
+ active,
29
+ color,
30
+ }: {
31
+ rect: OverlayRect;
32
+ label: string;
33
+ /** true = editing now (pulse, opacity 1); false = idle, cross-fading out. */
34
+ active: boolean;
35
+ /**
36
+ * Per-agent presence color (Phase 13.2 / DDR-078). Overrides the default
37
+ * `--mdcc-activity` hue so each agent's rim + badge carry its own color.
38
+ * Omitted for manual / non-agent edits (default inspector blue).
39
+ */
40
+ color?: string;
41
+ }) {
42
+ const style: CSSProperties = {
43
+ position: 'absolute',
44
+ left: rect.x,
45
+ top: rect.y,
46
+ width: rect.w,
47
+ height: rect.h,
48
+ pointerEvents: 'none',
49
+ // The injected CSS reads `var(--mdcc-activity)`; setting it here recolors the
50
+ // border, the ::after glow, and the badge background in one shot.
51
+ ...(color ? ({ '--mdcc-activity': color } as CSSProperties) : {}),
52
+ };
53
+ return (
54
+ <div
55
+ className="dc-activity-rim"
56
+ data-fading={active ? undefined : 'true'}
57
+ aria-hidden
58
+ style={style}
59
+ >
60
+ {/* Phase 13.3 — full-artboard scan beam (top→bottom sweep). Styled +
61
+ animated by the injected inspector CSS; this just mounts the cover. */}
62
+ <div className="dc-activity-scan" />
63
+ <span className="dc-activity-badge">editing — {label}</span>
64
+ </div>
65
+ );
66
+ }
67
+ ArtboardActivityOverlay.displayName = 'ArtboardActivityOverlay';
@@ -27,9 +27,12 @@
27
27
  */
28
28
 
29
29
  import {
30
+ Component,
30
31
  type ComponentType,
32
+ Fragment,
31
33
  type ReactNode,
32
34
  createElement,
35
+ useCallback,
33
36
  useEffect,
34
37
  useMemo,
35
38
  useRef,
@@ -347,6 +350,27 @@ export interface MountCanvasOptions {
347
350
  commentsEnabled: boolean;
348
351
  }
349
352
 
353
+ // The lite provider tree wrapping a canvas component (tool + selection +
354
+ // comment layer). Pulled out so both the live render and the error-fallback
355
+ // render build the identical envelope.
356
+ //
357
+ // NB: the CanvasActivityProvider (Phase 13 / DDR-029) is NOT mounted here — it
358
+ // lives inside `DesignCanvas` (canvas-lib). comment-mount.js and canvas-lib are
359
+ // SEPARATE bundles, so a context provided here would be a different instance
360
+ // from the one DCArtboard (canvas-lib) consumes. Same reasoning the real
361
+ // ToolProvider lives in DesignCanvas, not in this layer's MaybeToolProvider.
362
+ function buildCanvasTree(Canvas: ComponentType, file: string | undefined): ReactNode {
363
+ return createElement(
364
+ MaybeToolProvider,
365
+ null,
366
+ createElement(
367
+ MaybeSelectionSetProvider,
368
+ null,
369
+ createElement(CommentHost, { file }, createElement(Canvas))
370
+ )
371
+ );
372
+ }
373
+
350
374
  export function mountCanvas(Canvas: ComponentType, opts: MountCanvasOptions): void {
351
375
  const root = createRoot(opts.rootEl);
352
376
  if (!opts.commentsEnabled) {
@@ -354,15 +378,146 @@ export function mountCanvas(Canvas: ComponentType, opts: MountCanvasOptions): vo
354
378
  return;
355
379
  }
356
380
  const file = opts.file ?? deriveFile();
357
- root.render(
381
+ root.render(createElement(CanvasHmrRuntime, { initialCanvas: Canvas, file }));
382
+ }
383
+
384
+ // ─────────────────────────────────────────────────────────────────────────────
385
+ // Phase 13.1 / DDR-077 — HMR error resilience during agent editing.
386
+ //
387
+ // When an agent (`/design:edit` / `/design:new`) live-edits a canvas, a
388
+ // half-saved file (missing import, undefined symbol, transpile error) used to
389
+ // blank the canvas to white: the shell did `location.reload()` straight into the
390
+ // broken module. The shell now soft-reloads (import-before-swap, see
391
+ // `templates/_shell.html`) so a build/import error never tears down the good
392
+ // render. This runtime closes the remaining gap — a *render-time* throw — by
393
+ // keeping the last good canvas mounted via an error boundary and surfacing a
394
+ // "holding last good" toast instead of a white screen. Strictly gated by the
395
+ // shell on agent-active; manual edits keep the plain reload (so this is inert
396
+ // for solo hand-editing). The runtime exposes its swap/hold API on
397
+ // `window.__maudeCanvasRuntime` (the same window-handshake style the shell
398
+ // already uses for `__canvas_rel__` etc.).
399
+
400
+ export interface CanvasRuntimeApi {
401
+ /** Swap in a freshly-imported canvas module's default export (success path). */
402
+ remount: (next: ComponentType) => void;
403
+ /** Show/hide the "holding last good" toast (build-error path from the shell). */
404
+ setHolding: (on: boolean, message?: string) => void;
405
+ }
406
+
407
+ const RUNTIME_KEY = '__maudeCanvasRuntime';
408
+
409
+ /**
410
+ * Fires `onOk` only when its subtree COMMITS — i.e. renders without throwing. A
411
+ * render-time throw in the canvas unwinds to the boundary before this commits,
412
+ * so `onOk` never runs and `lastGood` is not advanced to a broken module.
413
+ */
414
+ function OkSignal({
415
+ Canvas,
416
+ file,
417
+ onOk,
418
+ }: {
419
+ Canvas: ComponentType;
420
+ file: string | undefined;
421
+ onOk: () => void;
422
+ }) {
423
+ // biome-ignore lint/correctness/useExhaustiveDependencies: fire once per mount (key=attempt remounts this).
424
+ useEffect(() => {
425
+ onOk();
426
+ }, []);
427
+ return buildCanvasTree(Canvas, file);
428
+ }
429
+
430
+ export class CanvasErrorBoundary extends Component<
431
+ {
432
+ attempt: number;
433
+ onError: () => void;
434
+ fallback: () => ReactNode;
435
+ children: ReactNode;
436
+ },
437
+ { hasError: boolean }
438
+ > {
439
+ state = { hasError: false };
440
+ static getDerivedStateFromError(): { hasError: boolean } {
441
+ return { hasError: true };
442
+ }
443
+ componentDidCatch(): void {
444
+ this.props.onError();
445
+ }
446
+ componentDidUpdate(prev: { attempt: number }): void {
447
+ // A new canvas (new attempt) arrived → clear the error and try rendering it.
448
+ if (prev.attempt !== this.props.attempt && this.state.hasError) {
449
+ this.setState({ hasError: false });
450
+ }
451
+ }
452
+ render(): ReactNode {
453
+ if (this.state.hasError) return this.props.fallback();
454
+ return this.props.children;
455
+ }
456
+ }
457
+
458
+ function HmrHoldingToast({ message }: { message?: string }): ReactNode {
459
+ return createElement(
460
+ 'div',
461
+ {
462
+ className: 'dc-hmr-holding',
463
+ role: 'status',
464
+ 'aria-live': 'polite',
465
+ title: message ? `Holding last working render — ${message}` : 'Holding last working render',
466
+ },
467
+ '⏸ build error — držím poslední funkční verzi'
468
+ );
469
+ }
470
+
471
+ function CanvasHmrRuntime({
472
+ initialCanvas,
473
+ file,
474
+ }: {
475
+ initialCanvas: ComponentType;
476
+ file: string | undefined;
477
+ }): ReactNode {
478
+ const [{ canvas, attempt }, setCanvasState] = useState({ canvas: initialCanvas, attempt: 0 });
479
+ const [holding, setHoldingState] = useState<{ on: boolean; message?: string }>({ on: false });
480
+ const lastGood = useRef<ComponentType | null>(null);
481
+ const canvasRef = useRef(canvas);
482
+ canvasRef.current = canvas;
483
+
484
+ // Publish the runtime API for the shell HMR client.
485
+ useEffect(() => {
486
+ const api: CanvasRuntimeApi = {
487
+ remount: (next) => setCanvasState((s) => ({ canvas: next, attempt: s.attempt + 1 })),
488
+ setHolding: (on, message) => setHoldingState(on ? { on: true, message } : { on: false }),
489
+ };
490
+ (window as unknown as Record<string, unknown>)[RUNTIME_KEY] = api;
491
+ return () => {
492
+ (window as unknown as Record<string, unknown>)[RUNTIME_KEY] = undefined;
493
+ };
494
+ }, []);
495
+
496
+ const handleOk = useCallback(() => {
497
+ lastGood.current = canvasRef.current;
498
+ // The new canvas rendered clean → drop any holding state.
499
+ setHoldingState((h) => (h.on ? { on: false } : h));
500
+ }, []);
501
+
502
+ const handleError = useCallback(() => {
503
+ setHoldingState({ on: true, message: 'render error' });
504
+ }, []);
505
+
506
+ const fallback = useCallback((): ReactNode => {
507
+ const LG = lastGood.current;
508
+ return LG ? buildCanvasTree(LG, file) : null;
509
+ }, [file]);
510
+
511
+ return createElement(
512
+ Fragment,
513
+ null,
358
514
  createElement(
359
- MaybeToolProvider,
360
- null,
361
- createElement(
362
- MaybeSelectionSetProvider,
363
- null,
364
- createElement(CommentHost, { file }, createElement(Canvas))
365
- )
366
- )
515
+ CanvasErrorBoundary,
516
+ { attempt, onError: handleError, fallback },
517
+ // key=attempt → each soft-reload remounts a fresh subtree (and resets the
518
+ // boundary's child), matching the clean-slate semantics of a full reload.
519
+ createElement(OkSignal, { key: attempt, Canvas: canvas, file, onOk: handleOk })
520
+ ),
521
+ holding.on ? createElement(HmrHoldingToast, { message: holding.message }) : null
367
522
  );
368
523
  }
@@ -90,12 +90,19 @@ import {
90
90
  useReducedMotion as _useReducedMotion,
91
91
  } from 'motion/react';
92
92
 
93
+ import { ArtboardActivityOverlay } from './artboard-activity-overlay.tsx';
93
94
  import { CanvasShell } from './canvas-shell.tsx';
94
95
  import {
95
96
  buildMoveArtboardsRecord,
96
97
  diffLayoutPositions,
97
98
  } from './commands/move-artboards-command.ts';
99
+ import { AgentPresenceProvider, useAgentPresence } from './use-agent-presence.tsx';
98
100
  import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
101
+ import {
102
+ CanvasActivityProvider,
103
+ matchesArtboard,
104
+ useCanvasActivity,
105
+ } from './use-canvas-activity.tsx';
99
106
  import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
100
107
  import { useSelectionSetOptional } from './use-selection-set.tsx';
101
108
  import { MaybeToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
@@ -1192,9 +1199,22 @@ export function DesignCanvas(props: DesignCanvasProps) {
1192
1199
  </MaybeToolProvider>
1193
1200
  );
1194
1201
  return (
1195
- <UndoStackProvider canvasFile={canvasFile}>
1196
- {collabSlug ? <CollabProvider slug={collabSlug}>{inner}</CollabProvider> : inner}
1197
- </UndoStackProvider>
1202
+ // Phase 13 / DDR-029 — the activity context MUST be provided here (canvas-lib
1203
+ // bundle) so DCArtboard's `useCanvasActivity()` reads the SAME context
1204
+ // instance. Providing it from comment-mount.js (a separate bundle) would
1205
+ // create a second ActivityContext the consumer never sees. `canvasFile` is
1206
+ // normalized to the server's design-root-relative activity key inside the
1207
+ // provider (falls back to window.__canvas_rel__).
1208
+ <CanvasActivityProvider file={canvasFile}>
1209
+ {/* Phase 13.2 / DDR-078 — agent presence. Same canvas-lib-bundle rule as
1210
+ the activity context: provided here so DCArtboard + ParticipantsChrome
1211
+ (both canvas-lib) read one context instance. */}
1212
+ <AgentPresenceProvider file={canvasFile}>
1213
+ <UndoStackProvider canvasFile={canvasFile}>
1214
+ {collabSlug ? <CollabProvider slug={collabSlug}>{inner}</CollabProvider> : inner}
1215
+ </UndoStackProvider>
1216
+ </AgentPresenceProvider>
1217
+ </CanvasActivityProvider>
1198
1218
  );
1199
1219
  }
1200
1220
  DesignCanvas.displayName = 'DesignCanvas';
@@ -1510,6 +1530,12 @@ export function DCArtboard({
1510
1530
  const toolMode = useToolModeOptional();
1511
1531
  const selSet = useSelectionSetOptional();
1512
1532
  const dragBus = useDragStateContext();
1533
+ // Phase 13 / DDR-029 — live "agent works here" overlay. Inert (present=false)
1534
+ // outside CanvasActivityProvider, so specimens / legacy mounts never show it.
1535
+ const activity = useCanvasActivity();
1536
+ // Phase 13.2 / DDR-078 — when an agent is the editor, tint the overlay with
1537
+ // its presence color + show its funny name instead of the generic file label.
1538
+ const agent = useAgentPresence();
1513
1539
  const rect = ctx ? ctx.rectFor(id) : null;
1514
1540
 
1515
1541
  // Drag hook — always called (hook rules). Inert outside DesignCanvas
@@ -1583,19 +1609,40 @@ export function DCArtboard({
1583
1609
 
1584
1610
  const handleProps = dragHook.bindHandle();
1585
1611
 
1612
+ // Phase 13 — overlay when THIS canvas is active and the change scope includes
1613
+ // this artboard (null scope = file-level = every artboard). Rendered as a
1614
+ // world-coord sibling of the <article> so it pans/zooms with the artboard.
1615
+ const showActivity = activity.present && matchesArtboard(activity.artboardIds, id);
1616
+ // Agent-driven → funny name + agent color; manual edit → file label + default hue.
1617
+ const activityLabel = agent
1618
+ ? agent.name
1619
+ : activity.artboardIds
1620
+ ? `${activity.fileLabel}:${label}`
1621
+ : activity.fileLabel;
1622
+
1586
1623
  return (
1587
- <article
1588
- className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
1589
- data-dc-screen={id}
1590
- aria-current={isActive ? 'true' : undefined}
1591
- style={{ left: liveX, top: liveY, width: rect.w, height: rect.h }}
1592
- {...handleProps}
1593
- >
1594
- <button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
1595
- {label}
1596
- </button>
1597
- <div className="dc-artboard-body">{children}</div>
1598
- </article>
1624
+ <>
1625
+ <article
1626
+ className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
1627
+ data-dc-screen={id}
1628
+ aria-current={isActive ? 'true' : undefined}
1629
+ style={{ left: liveX, top: liveY, width: rect.w, height: rect.h }}
1630
+ {...handleProps}
1631
+ >
1632
+ <button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
1633
+ {label}
1634
+ </button>
1635
+ <div className="dc-artboard-body">{children}</div>
1636
+ </article>
1637
+ {showActivity ? (
1638
+ <ArtboardActivityOverlay
1639
+ rect={{ x: liveX, y: liveY, w: rect.w, h: rect.h }}
1640
+ label={activityLabel}
1641
+ active={activity.active}
1642
+ color={agent?.color}
1643
+ />
1644
+ ) : null}
1645
+ </>
1599
1646
  );
1600
1647
  }
1601
1648
  DCArtboard.displayName = 'DCArtboard';
@@ -206,7 +206,8 @@
206
206
  },
207
207
  "syncTsx": {
208
208
  "type": "boolean",
209
- "description": "DDR-072 — project-level TSX sync opt-in. When true, ALL .tsx canvases sync to this hub without a per-canvas .meta.json \"syncable\": true. A per-canvas \"syncable\": false still excludes individual canvases (the sidecar always wins). Inert unless the cross-origin sandbox is active (MAUDE_CANVAS_ORIGIN_SPLIT != 0) — the DDR-060 Lock-2 coupling is preserved. Broadens the WebRTC/self-nav exfil residual to every synced canvas, so only enable for hubs you operate or fully trust; the dev-server prints a loud boot banner against non-loopback hubs."
209
+ "default": true,
210
+ "description": "DDR-079 (supersedes DDR-072) — project-level TSX sync, ON BY DEFAULT. Omit the field to sync ALL .tsx canvases to this hub (the common case: a linked teammate sees the project's TSX without a hidden opt-in). Set false to opt the whole project OUT. A per-canvas .meta.json \"syncable\": false still excludes one canvas (the sidecar always wins). Inert unless the cross-origin sandbox is active (MAUDE_CANVAS_ORIGIN_SPLIT != 0) — the DDR-060 Lock-2 coupling is preserved. TSX bodies execute, so syncing broadens a WebRTC/self-nav exfil residual to every synced canvas — link only hubs you operate or trust; the dev-server prints a loud boot banner against non-loopback hubs."
210
211
  }
211
212
  },
212
213
  "additionalProperties": false