@1agh/maude 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +16 -8
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
  23. package/plugins/design/dev-server/annotations-layer.tsx +52 -12
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
  26. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  27. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  28. package/plugins/design/dev-server/canvas-lib.tsx +33 -7
  29. package/plugins/design/dev-server/canvas-shell.tsx +127 -9
  30. package/plugins/design/dev-server/client/app.jsx +72 -0
  31. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  32. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  33. package/plugins/design/dev-server/collab/index.ts +47 -0
  34. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  35. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  36. package/plugins/design/dev-server/collab/registry.ts +110 -0
  37. package/plugins/design/dev-server/collab/room.ts +215 -0
  38. package/plugins/design/dev-server/comments-overlay.tsx +29 -0
  39. package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
  40. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  41. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  42. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  43. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  44. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  45. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  46. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  47. package/plugins/design/dev-server/http.ts +64 -1
  48. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  49. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  50. package/plugins/design/dev-server/runtime-bundle.ts +8 -0
  51. package/plugins/design/dev-server/server.ts +78 -11
  52. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  53. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  54. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  55. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  56. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  57. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  58. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  59. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  60. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  61. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  62. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  63. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  64. package/plugins/design/dev-server/use-collab.tsx +478 -0
  65. package/plugins/design/dev-server/ws.ts +123 -7
  66. package/plugins/design/templates/_shell.html +35 -1
  67. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  68. package/plugins/flow/dependencies.json +143 -0
  69. package/plugins/flow/dependencies.schema.json +107 -0
@@ -140,6 +140,12 @@ export interface Api {
140
140
  commentsDelete(id: string): Promise<boolean>;
141
141
  commentsAddReply(id: string, payload: { body: string; author?: string }): Promise<Comment | null>;
142
142
  gitCommitters(): Promise<GitCommitter[]>;
143
+ /**
144
+ * Phase 8 — local `git config user.name`, cached for the process lifetime.
145
+ * Used by the collab client to derive a stable color hash per peer.
146
+ * Empty string when git is unset; the client falls back to `anonymous-<pid>`.
147
+ */
148
+ gitCurrentUser(): Promise<string>;
143
149
  parseMentions(text: string): string[];
144
150
  // Canvas state
145
151
  loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
@@ -161,7 +167,15 @@ export interface Api {
161
167
  appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
162
168
  }
163
169
 
164
- export function createApi(ctx: Context, onCommentsChanged: (file: string) => void): Api {
170
+ export interface ApiHooks {
171
+ onCommentsChanged: (file: string) => void;
172
+ /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
173
+ onAnnotationsChanged?: (file: string, svg: string) => void;
174
+ }
175
+
176
+ export function createApi(ctx: Context, hooks: ApiHooks): Api {
177
+ const onCommentsChanged = hooks.onCommentsChanged;
178
+ const onAnnotationsChanged = hooks.onAnnotationsChanged;
165
179
  const { paths, cfg } = ctx;
166
180
 
167
181
  function fileSlug(file: string): string {
@@ -576,6 +590,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
576
590
  // content fully, so we don't try to sanitize beyond a tag check.
577
591
  if (!/^\s*<svg[\s>]/i.test(svg)) return false;
578
592
  await Bun.write(annotationsPath(file), svg);
593
+ onAnnotationsChanged?.(file, svg);
579
594
  return true;
580
595
  }
581
596
 
@@ -936,6 +951,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
936
951
  commentsDelete,
937
952
  commentsAddReply,
938
953
  gitCommitters,
954
+ gitCurrentUser,
939
955
  parseMentions,
940
956
  loadCanvasState,
941
957
  saveCanvasState,
@@ -27,8 +27,8 @@ const MARQUEE_CSS = `
27
27
  position: fixed;
28
28
  pointer-events: none;
29
29
  z-index: 5;
30
- border: 1px solid var(--accent, #0d99ff);
31
- background: color-mix(in oklab, var(--accent, #0d99ff) 8%, transparent);
30
+ border: 1px solid var(--maude-hud-accent, #0d99ff);
31
+ background: color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 8%, transparent);
32
32
  display: none;
33
33
  }
34
34
  `.trim();
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # preflight.sh — design plugin dependency health check.
3
+ # Thin shell wrapper over `node cli/lib/preflight.mjs --plugin design`.
4
+ # Keeps the detection logic in one place (the .mjs lib) while exposing the
5
+ # bash-friendly modes (--shell-export, --warn-only) that init.md and the
6
+ # SessionStart hook need.
7
+ #
8
+ # Usage:
9
+ # preflight.sh # text table (humans)
10
+ # preflight.sh --json # machine-readable envelope
11
+ # preflight.sh --shell-export # `export DEPS_OK=... DEPS_MISSING=...`
12
+ # preflight.sh --quiet # silent on success, one-liner on miss
13
+ # preflight.sh --warn-only # same as --quiet (SessionStart hook)
14
+ #
15
+ # Resolution (DDR-045): never compute paths from `dirname $0` inside a
16
+ # `bun --compile` standalone binary — that maps to /$bunfs/root. The CLI
17
+ # entry stays Node, so we resolve via $CLAUDE_PLUGIN_ROOT first, with the
18
+ # script-dir fallback only used when running uncompiled from the repo.
19
+ #
20
+ # Exit: 0 if all hard deps pass; 1 otherwise.
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
23
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
24
+ PKG_ROOT="$(cd "$PLUGIN_ROOT/../.." && pwd)"
25
+
26
+ # The Node lib needs cwd to be the package root (it resolves dependencies.json
27
+ # via `plugins/<plugin>/dependencies.json`). Subshell so we don't mutate the
28
+ # caller's cwd.
29
+ (
30
+ cd "$PKG_ROOT" || exit 1
31
+ exec node "$PKG_ROOT/cli/lib/preflight.mjs" --plugin design "$@"
32
+ )
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bash
2
+ # runtime-health.sh — verify the dev-server is serving the on-disk pre-built
3
+ # runtime bundles, not a defective dynamic Bun.build output.
4
+ #
5
+ # Why: a stale dev-server process can cache a broken dynamic build of
6
+ # /_canvas-runtime/<slug>.js (e.g. a 409-line motion_react.js with a hoisting
7
+ # bug instead of the 10056-line pre-built). The canvas TSX parses cleanly,
8
+ # server returns HTTP 200, but the iframe throws at module-eval time with
9
+ # `ReferenceError: AcceleratedAnimation is not defined` (or similar).
10
+ #
11
+ # Parse-clean ≠ run-clean. This helper closes that gap: probe every URL the
12
+ # canvas-lib pulls in, compare byte-count to the disk pre-built, fail loud
13
+ # when the served body is suspiciously small.
14
+ #
15
+ # Usage:
16
+ # runtime-health.sh [--port N] [--root <repo>] [--threshold 0.5]
17
+ # [--restart] [--quiet]
18
+ #
19
+ # Behavior:
20
+ # --restart → on detected defect, kill the server PID from _server.json
21
+ # and respawn via server-up.sh. Exit 0 if the restart heals.
22
+ # --quiet → silence per-bundle PASS lines; only print failures + summary.
23
+ #
24
+ # Exit codes:
25
+ # 0 all bundles healthy (or --restart healed the defect)
26
+ # 1 server unreachable / no _server.json
27
+ # 2 bad args
28
+ # 3 defective bundle detected (and either no --restart, or restart did not heal)
29
+
30
+ PORT=""
31
+ REPO=""
32
+ THRESHOLD="0.5"
33
+ RESTART=0
34
+ QUIET=0
35
+
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ --port) PORT="$2"; shift 2 ;;
39
+ --root) REPO="$2"; shift 2 ;;
40
+ --threshold) THRESHOLD="$2"; shift 2 ;;
41
+ --restart) RESTART=1; shift ;;
42
+ --quiet) QUIET=1; shift ;;
43
+ --help|-h)
44
+ sed -n '2,30p' "$0" | sed 's/^# \?//'
45
+ exit 0
46
+ ;;
47
+ *)
48
+ echo "runtime-health.sh: unknown arg '$1' (try --help)" >&2
49
+ exit 2
50
+ ;;
51
+ esac
52
+ done
53
+
54
+ # ---------- repo + plugin root resolution ----------
55
+ if [ -z "$REPO" ]; then
56
+ REPO="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
57
+ fi
58
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
59
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
60
+ PREBUILT_DIR="$PLUGIN_ROOT/dev-server/dist/runtime"
61
+ if [ ! -d "$PREBUILT_DIR" ]; then
62
+ PREBUILT_DIR="$SCRIPT_DIR/../dist/runtime"
63
+ fi
64
+ if [ ! -d "$PREBUILT_DIR" ]; then
65
+ echo "runtime-health.sh: pre-built runtime dir not found at $PREBUILT_DIR" >&2
66
+ echo " (canvas runtime bundles ship in <plugin>/dev-server/dist/runtime/)" >&2
67
+ exit 1
68
+ fi
69
+
70
+ # ---------- resolve port from _server.json if not given ----------
71
+ DESIGN_ROOT="$REPO/.design"
72
+ STATE="$DESIGN_ROOT/_server.json"
73
+ if [ -z "$PORT" ]; then
74
+ if [ ! -f "$STATE" ]; then
75
+ echo "runtime-health.sh: no --port given and $STATE not found (run server-up.sh first)" >&2
76
+ exit 1
77
+ fi
78
+ if command -v jq >/dev/null 2>&1; then
79
+ PORT=$(jq -r .port "$STATE" 2>/dev/null)
80
+ else
81
+ PORT=$(sed -nE 's/.*"port"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' "$STATE" | head -n1)
82
+ fi
83
+ fi
84
+ [ -z "$PORT" ] && { echo "runtime-health.sh: could not resolve port" >&2; exit 1; }
85
+
86
+ BASE="http://localhost:$PORT"
87
+
88
+ # Confirm server alive before probing individual bundles.
89
+ if ! curl -fs "$BASE/_health" >/dev/null 2>&1; then
90
+ echo "runtime-health.sh: server not responding at $BASE/_health" >&2
91
+ exit 1
92
+ fi
93
+
94
+ # ---------- per-bundle probe ----------
95
+ probe_one() {
96
+ local slug="$1"
97
+ local disk_size="$2"
98
+ local url="$BASE/_canvas-runtime/$slug"
99
+ local served
100
+ served=$(curl -fs -o /dev/null -w '%{size_download}' "$url" 2>/dev/null) || {
101
+ echo "✗ $slug — served HTTP error (curl failed for $url)" >&2
102
+ return 1
103
+ }
104
+ # Floor at 256 bytes — any working ESM bundle is bigger than that.
105
+ if [ "$served" -lt 256 ]; then
106
+ echo "✗ $slug — served body $served B < 256 B floor (server returned empty)" >&2
107
+ return 1
108
+ fi
109
+ # Threshold ratio: served must be ≥ threshold × disk.
110
+ # awk handles the fractional math without bc dependency.
111
+ local ratio
112
+ ratio=$(awk -v s="$served" -v d="$disk_size" 'BEGIN{ if(d==0){print 0}else{printf "%.3f", s/d} }')
113
+ local ok
114
+ ok=$(awk -v r="$ratio" -v t="$THRESHOLD" 'BEGIN{print (r >= t) ? 1 : 0}')
115
+ if [ "$ok" = "1" ]; then
116
+ [ $QUIET -eq 0 ] && echo "✓ $slug — $served B / $disk_size B disk (ratio $ratio)" >&2
117
+ return 0
118
+ fi
119
+ echo "✗ $slug — served $served B / $disk_size B disk (ratio $ratio < $THRESHOLD) — defective dynamic build" >&2
120
+ return 1
121
+ }
122
+
123
+ FAIL_COUNT=0
124
+ FAIL_LIST=""
125
+ for f in "$PREBUILT_DIR"/*.js; do
126
+ [ -f "$f" ] || continue
127
+ SLUG=$(basename "$f")
128
+ DISK_SIZE=$(wc -c < "$f" | tr -d ' ')
129
+ if ! probe_one "$SLUG" "$DISK_SIZE"; then
130
+ FAIL_COUNT=$((FAIL_COUNT + 1))
131
+ FAIL_LIST="$FAIL_LIST $SLUG"
132
+ fi
133
+ done
134
+
135
+ if [ "$FAIL_COUNT" -eq 0 ]; then
136
+ [ $QUIET -eq 0 ] && echo "✓ runtime-health OK — all bundles match disk pre-built (threshold $THRESHOLD)" >&2
137
+ exit 0
138
+ fi
139
+
140
+ echo "" >&2
141
+ echo "✗ runtime-health FAIL — $FAIL_COUNT bundle(s) below threshold:$FAIL_LIST" >&2
142
+ echo " These bundles look like defective dynamic Bun.build output." >&2
143
+ echo " The canvas TSX will parse + serve cleanly, but the iframe will throw at runtime." >&2
144
+
145
+ if [ "$RESTART" -eq 1 ]; then
146
+ echo "→ --restart given; killing server and respawning via server-up.sh" >&2
147
+ if [ -f "$STATE" ]; then
148
+ if command -v jq >/dev/null 2>&1; then
149
+ PID=$(jq -r .pid "$STATE" 2>/dev/null)
150
+ else
151
+ PID=$(sed -nE 's/.*"pid"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' "$STATE" | head -n1)
152
+ fi
153
+ if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
154
+ kill "$PID" 2>/dev/null
155
+ sleep 1
156
+ kill -0 "$PID" 2>/dev/null && kill -9 "$PID" 2>/dev/null
157
+ fi
158
+ rm -f "$STATE"
159
+ fi
160
+ NEW_PORT=$(bash "$SCRIPT_DIR/server-up.sh" --root "$REPO")
161
+ [ -z "$NEW_PORT" ] && { echo "✗ server-up.sh did not return a port after restart" >&2; exit 3; }
162
+ echo "→ server restarted port=$NEW_PORT; re-probing once" >&2
163
+ # Re-probe via tail-recursive invocation with no --restart (avoid infinite loop).
164
+ bash "$0" --port "$NEW_PORT" --root "$REPO" --threshold "$THRESHOLD" $( [ $QUIET -eq 1 ] && echo --quiet )
165
+ exit $?
166
+ fi
167
+
168
+ echo " Remediation: rerun with --restart, OR manually 'kill $(cat $STATE 2>/dev/null | sed -nE 's/.*pid.*:.*([0-9]+).*/\1/p') && bash $SCRIPT_DIR/server-up.sh'" >&2
169
+ exit 3
@@ -93,6 +93,7 @@ import {
93
93
  diffLayoutPositions,
94
94
  } from './commands/move-artboards-command.ts';
95
95
  import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
96
+ import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
96
97
  import { useSelectionSetOptional } from './use-selection-set.tsx';
97
98
  import { ToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
98
99
  import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
@@ -227,7 +228,7 @@ button.dc-artboard-label {
227
228
  display: block;
228
229
  width: 100%;
229
230
  }
230
- button.dc-artboard-label:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: -2px; }
231
+ button.dc-artboard-label:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: -2px; }
231
232
  /* Active-artboard ring is in canvas-shell HALO_CSS (subtle 1 px tint). */
232
233
  /* Phase 4.2 — drag chrome. */
233
234
  .dc-canvas[data-active-tool="move"] .dc-artboard-label { cursor: grab; }
@@ -1170,11 +1171,20 @@ export function DesignCanvas(props: DesignCanvasProps) {
1170
1171
  // `window.top.__maude_undo_stacks` so it survives canvas switches —
1171
1172
  // close Foo.tsx, open Bar.tsx, come back to Foo.tsx → history intact.
1172
1173
  const canvasFile = readCanvasMetaFile() ?? undefined;
1174
+ // Phase 8 / DDR-051 — open a Yjs collab session for this canvas iff we can
1175
+ // derive a stable slug. The slug must match `api.fileSlug` server-side so
1176
+ // both ends agree on the room key. When the canvas was opened via a URL
1177
+ // that doesn't yield a slug (e.g. preview iframes without `canvas=`),
1178
+ // CollabProvider is omitted; useCollab() falls back gracefully to null.
1179
+ const collabSlug = canvasSlugFromPath(canvasFile);
1180
+ const inner = (
1181
+ <ToolProvider>
1182
+ <DesignCanvasInner {...props} />
1183
+ </ToolProvider>
1184
+ );
1173
1185
  return (
1174
1186
  <UndoStackProvider canvasFile={canvasFile}>
1175
- <ToolProvider>
1176
- <DesignCanvasInner {...props} />
1177
- </ToolProvider>
1187
+ {collabSlug ? <CollabProvider slug={collabSlug}>{inner}</CollabProvider> : inner}
1178
1188
  </UndoStackProvider>
1179
1189
  );
1180
1190
  }
@@ -1228,6 +1238,22 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1228
1238
  setArtboards(initialArtboards());
1229
1239
  }, [initialArtboards]);
1230
1240
 
1241
+ // Phase 8 — foreign canvas-meta change. The shell-level HMR client
1242
+ // re-fetches `<canvas>.meta.json` and dispatches `maude:meta-refreshed`
1243
+ // when *another* tab PATCHed the layout (drag, distribute, align). We
1244
+ // re-apply positions in-place — no full reload — so the user's tool mode,
1245
+ // undo stack, scroll, and selection state survive. Self-writes are
1246
+ // suppressed at the dispatch site via the `__maude_last_meta_self_write_at`
1247
+ // echo timestamp.
1248
+ useEffect(() => {
1249
+ if (typeof document === 'undefined') return;
1250
+ const onRefresh = () => {
1251
+ setArtboards(initialArtboards());
1252
+ };
1253
+ document.addEventListener('maude:meta-refreshed', onRefresh);
1254
+ return () => document.removeEventListener('maude:meta-refreshed', onRefresh);
1255
+ }, [initialArtboards]);
1256
+
1231
1257
  // Stable refs so the controller's callbacks always see the latest values.
1232
1258
  const artboardsRef = useRef(artboards);
1233
1259
  artboardsRef.current = artboards;
@@ -1736,8 +1762,8 @@ const OVERLAY_CSS = `
1736
1762
  outline-only. Reads from a glance as "what slice of the world you're on". */
1737
1763
  .dc-mm-vp {
1738
1764
  position: absolute;
1739
- background: color-mix(in oklab, var(--accent, #d63b1f) 12%, transparent);
1740
- border: 1.5px solid var(--accent, #d63b1f);
1765
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 12%, transparent);
1766
+ border: 1.5px solid var(--maude-hud-accent, #d63b1f);
1741
1767
  border-radius: 1px;
1742
1768
  pointer-events: none;
1743
1769
  }
@@ -1773,7 +1799,7 @@ const OVERLAY_CSS = `
1773
1799
  }
1774
1800
  .dc-zoom-tb button:last-child { border-right: 0; }
1775
1801
  .dc-zoom-tb button:hover { background: color-mix(in oklab, var(--fg-0, #1c1917) 5%, transparent); }
1776
- .dc-zoom-tb button:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: -2px; }
1802
+ .dc-zoom-tb button:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: -2px; }
1777
1803
  .dc-zoom-tb-pct { font-variant-numeric: tabular-nums; min-width: 52px; }
1778
1804
  `.trim();
1779
1805
 
@@ -36,6 +36,7 @@ import {
36
36
  useState,
37
37
  } from 'react';
38
38
 
39
+ import { AiBanner } from './ai-banner.tsx';
39
40
  import { AnnotationsLayer } from './annotations-layer.tsx';
40
41
  import { ArtboardMarqueeOverlay } from './artboard-marquee.tsx';
41
42
  import {
@@ -57,17 +58,21 @@ import {
57
58
  useContextMenu,
58
59
  } from './context-menu.tsx';
59
60
  import { ContextualToolbar } from './contextual-toolbar.tsx';
61
+ import { CursorsOverlay } from './cursors-overlay.tsx';
60
62
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
61
63
  import { ExportDialogProvider } from './export-dialog.tsx';
62
64
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
63
65
  import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
66
+ import { ParticipantsChrome } from './participants-chrome.tsx';
64
67
  import { ToolPalette } from './tool-palette.tsx';
65
68
  import { UndoHud } from './undo-hud.tsx';
66
69
  import {
67
70
  AnnotationSelectionProvider,
68
71
  useAnnotationSelection,
72
+ useAnnotationSelectionOptional,
69
73
  } from './use-annotation-selection.tsx';
70
74
  import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
75
+ import { useCollab } from './use-collab.tsx';
71
76
  import { useCursorModifiers } from './use-cursor-modifiers.tsx';
72
77
  import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
73
78
  import { type Selection, SelectionSetProvider, useSelectionSet } from './use-selection-set.tsx';
@@ -81,6 +86,28 @@ import { useUndoStack } from './use-undo-stack.tsx';
81
86
  // plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
82
87
  // = invisible). No per-element class stamping is used.
83
88
 
89
+ // HUD-namespace token block. System-review 2026-05-27 (D-4) flagged that the
90
+ // dev-server chrome (toolbar + minimap + halos + marquee + AI banner) used
91
+ // `var(--accent, …)` which inherited the canvas DS palette — a violet StudyFi
92
+ // canvas turned the floating cursor toolbar violet. The HUD owns its own
93
+ // `--maude-hud-*` token family, set on `:root` of the canvas iframe document
94
+ // here. Canvas DSs do NOT define `--maude-hud-*`, so HUD CSS resolves against
95
+ // this block regardless of what the imported `:root { --accent: … }` looks like.
96
+ //
97
+ // Defaults match the existing inline fallback color (`#d63b1f`, Maude brand
98
+ // orange-rust) so no visual change to the default theme. Users who want to
99
+ // re-theme the HUD can set `--maude-hud-accent` etc. via a `<style>` block
100
+ // AFTER this one (CSS cascade — later wins).
101
+ const HUD_TOKENS_CSS = `
102
+ :root {
103
+ --maude-hud-accent: #d63b1f;
104
+ --maude-hud-accent-hover: #b8331b;
105
+ --maude-hud-accent-active: #962a16;
106
+ --maude-hud-accent-fg: #ffffff;
107
+ --maude-hud-accent-tint: color-mix(in oklab, #d63b1f 14%, transparent);
108
+ }
109
+ `;
110
+
84
111
  // DDR-046 — Three-state halo language. Each state has its own border weight,
85
112
  // color treatment, and geometric idiom so 8+ semantic states (hover / selected
86
113
  // / member-of-multi / group / snap-sibling / snap-grid / marquee / annotation
@@ -98,20 +125,20 @@ const HALO_CSS = `
98
125
  /* Hover — lighter 1.5px tinted line + white inner ring for contrast on dark
99
126
  elements. NO ring, NO ticks. Synchronous paint (no debounce). */
100
127
  .dc-cv-halo--hover {
101
- border: 1.5px solid color-mix(in oklab, var(--accent, #0d99ff) 60%, transparent);
128
+ border: 1.5px solid color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 60%, transparent);
102
129
  box-shadow: inset 0 0 0 1px var(--bg-0, #ffffff);
103
130
  }
104
131
  /* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
105
132
  Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
106
133
  .dc-cv-halo--selected {
107
- border: 2px solid var(--accent, #0d99ff);
108
- box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #0d99ff) 18%, transparent);
134
+ border: 2px solid var(--maude-hud-accent, #0d99ff);
135
+ box-shadow: 0 0 0 4px color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 18%, transparent);
109
136
  }
110
137
  .dc-cv-halo--selected .tick {
111
138
  position: absolute;
112
139
  width: 8px;
113
140
  height: 8px;
114
- background: var(--accent, #0d99ff);
141
+ background: var(--maude-hud-accent, #0d99ff);
115
142
  border-radius: 1px;
116
143
  box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
117
144
  }
@@ -125,7 +152,7 @@ const HALO_CSS = `
125
152
  as "draft / placeholder" and members would melt away inside artboards once
126
153
  the artboard border itself is 22%-tinted (T15). */
127
154
  .dc-cv-halo--selected-member {
128
- border: 1.5px solid var(--accent, #0d99ff);
155
+ border: 1.5px solid var(--maude-hud-accent, #0d99ff);
129
156
  }
130
157
  /* Group bbox — 1 px DASHED full accent + four 6 × 6 square corner handles.
131
158
  T16 / DDR-046 rev 2 — dashed is the canonical group-container affordance
@@ -138,14 +165,14 @@ const HALO_CSS = `
138
165
  position: fixed;
139
166
  pointer-events: none;
140
167
  z-index: 5;
141
- border: 1px dashed var(--accent, #0d99ff);
168
+ border: 1px dashed var(--maude-hud-accent, #0d99ff);
142
169
  border-radius: 2px;
143
170
  }
144
171
  .dc-cv-group-bbox .tick {
145
172
  position: absolute;
146
173
  width: 6px;
147
174
  height: 6px;
148
- background: var(--accent, #0d99ff);
175
+ background: var(--maude-hud-accent, #0d99ff);
149
176
  border-radius: 1px;
150
177
  box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
151
178
  }
@@ -165,7 +192,7 @@ const HALO_CSS = `
165
192
  (3 px ring + hard 6×6×0 offset) was readable but visually expensive once
166
193
  the frame itself lost its brutalist treatment. A 2 px ring on a 22 %
167
194
  tinted hairline reads unambiguous without claiming subject-ness. */
168
- box-shadow: 0 0 0 2px var(--accent, #0d99ff);
195
+ box-shadow: 0 0 0 2px var(--maude-hud-accent, #0d99ff);
169
196
  transition: box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1);
170
197
  }
171
198
  /* Respect prefers-reduced-motion across all chrome transitions. */
@@ -247,6 +274,15 @@ const HALO_CSS = `
247
274
 
248
275
  function ensureHaloStyles(): void {
249
276
  if (typeof document === 'undefined') return;
277
+ // HUD tokens MUST be injected before HALO_CSS so the cascade resolves
278
+ // `var(--maude-hud-accent, …)` against the dev-server's brand defaults
279
+ // even when the canvas DS's tokens.css later sets `:root { --accent: … }`.
280
+ if (!document.getElementById('dc-cv-hud-tokens-css')) {
281
+ const t = document.createElement('style');
282
+ t.id = 'dc-cv-hud-tokens-css';
283
+ t.textContent = HUD_TOKENS_CSS;
284
+ document.head.appendChild(t);
285
+ }
250
286
  if (document.getElementById('dc-cv-halo-css')) return;
251
287
  const s = document.createElement('style');
252
288
  s.id = 'dc-cv-halo-css';
@@ -364,6 +400,85 @@ function CanvasCore({
364
400
  return () => host.removeAttribute('data-cv-zoom-lod');
365
401
  }, [hostRef, publishedZoom]);
366
402
 
403
+ // Phase 8 — publish local cursor (world coords) + viewport to Awareness
404
+ // so foreign peers can render our cursor on their CursorsOverlay. The
405
+ // collab.publishAwareness call is already throttled to ~30 Hz internally
406
+ // (use-collab.tsx) — we just need to compute screen → world per move.
407
+ const collab = useCollab();
408
+ useEffect(() => {
409
+ const host = hostRef.current;
410
+ if (!host || !collab || !controller) return;
411
+ const onMove = (e: MouseEvent) => {
412
+ const v = controller.viewport;
413
+ // screen → world: world = (screen - viewport.{x,y}) / zoom.
414
+ const worldX = (e.clientX - v.x) / Math.max(v.zoom, 0.0001);
415
+ const worldY = (e.clientY - v.y) / Math.max(v.zoom, 0.0001);
416
+ collab.publishAwareness({ cursor: { x: worldX, y: worldY } });
417
+ };
418
+ const onLeave = () => {
419
+ collab.publishAwareness({ cursor: null });
420
+ };
421
+ host.addEventListener('mousemove', onMove);
422
+ host.addEventListener('mouseleave', onLeave);
423
+ return () => {
424
+ host.removeEventListener('mousemove', onMove);
425
+ host.removeEventListener('mouseleave', onLeave);
426
+ };
427
+ }, [hostRef, collab, controller]);
428
+
429
+ // Phase 8 — publish viewport when it settles. The CursorsOverlay only
430
+ // needs the LOCAL viewport (to transform foreign world coords back to
431
+ // screen), but exposing ours over Awareness sets up Task 6's follow-mode.
432
+ // biome-ignore lint/correctness/useExhaustiveDependencies: deliberate settle-cadence — re-publish only when x/y/zoom change, not on every viewport field.
433
+ useEffect(() => {
434
+ if (!collab || !controller) return;
435
+ collab.publishAwareness({ viewport: controller.viewport });
436
+ }, [
437
+ collab,
438
+ controller,
439
+ controller?.viewport.x,
440
+ controller?.viewport.y,
441
+ controller?.viewport.zoom,
442
+ ]);
443
+
444
+ // Phase 8 — publish local selection so foreign PeerSelection halos can
445
+ // render around what *I* selected. Convert the first entry's selector
446
+ // (CSS path the peer resolves in their own DOM) + current bounds. The
447
+ // bounds are screen-px from publish time — peers re-resolve cssPath
448
+ // when possible and fall back to bounds otherwise.
449
+ useEffect(() => {
450
+ if (!collab) return;
451
+ const first = selSet.selected[0];
452
+ if (!first || !first.selector) {
453
+ collab.publishAwareness({ selection: null });
454
+ return;
455
+ }
456
+ const b = first.bounds;
457
+ const bounds =
458
+ b && typeof b === 'object'
459
+ ? {
460
+ x: Number(b.x) || 0,
461
+ y: Number(b.y) || 0,
462
+ w: Number(b.w) || 0,
463
+ h: Number(b.h) || 0,
464
+ }
465
+ : { x: 0, y: 0, w: 0, h: 0 };
466
+ collab.publishAwareness({ selection: { cssPath: first.selector, bounds } });
467
+ }, [collab, selSet.selected]);
468
+
469
+ // Phase 8 — publish annotation selection (Phase 5 strokes). Separate from
470
+ // selSet because annotations have their own selection registry. Peers
471
+ // render halos by querying `[data-id="<id>"]` so the same halo follows
472
+ // resize / move (SVG re-emits with the same data-id). `Optional` flavor
473
+ // because CanvasCore is mounted INSIDE the AnnotationSelectionProvider
474
+ // tree but TypeScript / a defensive boot path can't always prove it.
475
+ const annotSelForPublish = useAnnotationSelectionOptional();
476
+ const annotSelectedIds = annotSelForPublish?.selectedIds;
477
+ useEffect(() => {
478
+ if (!collab) return;
479
+ collab.publishAwareness({ annotationSelection: annotSelectedIds ?? [] });
480
+ }, [collab, annotSelectedIds]);
481
+
367
482
  /**
368
483
  * T24 — distribute the currently-selected artboards evenly on the given
369
484
  * axis. Requires ≥ 3 selected artboards. Sort by leading edge, hold the
@@ -1017,6 +1132,9 @@ function CanvasRouter({
1017
1132
  />
1018
1133
  <SnapGuideOverlay />
1019
1134
  <UndoHud />
1135
+ <CursorsOverlay />
1136
+ <AiBanner />
1137
+ <ParticipantsChrome />
1020
1138
  </>
1021
1139
  );
1022
1140
  }
@@ -1062,7 +1180,7 @@ const MULTI_TOOLBAR_CSS = `
1062
1180
  transition: background-color 80ms linear;
1063
1181
  }
1064
1182
  .dc-multi-artboard-tb button:hover:not(:disabled) {
1065
- background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
1183
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
1066
1184
  }
1067
1185
  .dc-multi-artboard-tb button:disabled {
1068
1186
  cursor: default;
@@ -1483,6 +1483,10 @@ function App() {
1483
1483
  const [activePath, setActivePath] = useState(null);
1484
1484
  const [selected, setSelected] = useState(null);
1485
1485
  const [wsConnected, setWsConnected] = useState(false);
1486
+ // Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
1487
+ // every dirty Y.Doc to disk by the time this state populates, so accepting
1488
+ // the reload is data-loss-safe (DDR-051 §3).
1489
+ const [gitLifecycle, setGitLifecycle] = useState(null);
1486
1490
  const [search, setSearch] = useState('');
1487
1491
  const [systemData, setSystemData] = useState(null);
1488
1492
  // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
@@ -1658,6 +1662,23 @@ function App() {
1658
1662
  setSelected(m.selected);
1659
1663
  } else if (m.type === 'comments' && typeof m.file === 'string') {
1660
1664
  setCommentsByFile(prev => ({ ...prev, [m.file]: m.comments || [] }));
1665
+ } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
1666
+ // Phase 8 Task 4 — relay to every open iframe; each canvas's
1667
+ // AiBanner filters by its own file path. Lightweight broadcast
1668
+ // (one envelope per change, not per iframe count).
1669
+ for (const el of iframesRef.current.values()) {
1670
+ try { el.contentWindow.postMessage({ dgn: 'ai-activity', file: m.file, entry: m.entry }, '*'); } catch {}
1671
+ }
1672
+ } else if (m.type === 'git-lifecycle' && m.payload) {
1673
+ // Phase 8 Task 7 — branch switch / pull mid-session. Server has
1674
+ // already flushed every dirty Y.Doc to JSON; just prompt the user.
1675
+ // Single confirm covers all open iframes — reload reseeds them all.
1676
+ setGitLifecycle(m.payload);
1677
+ // Also relay to iframes so canvas-level "Reload?" UI (if any)
1678
+ // can react. Outer banner is the primary prompt.
1679
+ for (const el of iframesRef.current.values()) {
1680
+ try { el.contentWindow.postMessage({ dgn: 'git-lifecycle', payload: m.payload }, '*'); } catch {}
1681
+ }
1661
1682
  }
1662
1683
  } catch {}
1663
1684
  });
@@ -2000,6 +2021,57 @@ function App() {
2000
2021
  className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
2001
2022
  onContextMenu={onShellContextMenu}
2002
2023
  >
2024
+ {gitLifecycle && (
2025
+ <div
2026
+ role="status"
2027
+ aria-live="polite"
2028
+ style={{
2029
+ position: 'fixed',
2030
+ top: 12,
2031
+ left: '50%',
2032
+ transform: 'translateX(-50%)',
2033
+ zIndex: 10002,
2034
+ display: 'flex',
2035
+ alignItems: 'center',
2036
+ gap: 12,
2037
+ padding: '8px 14px',
2038
+ background: '#dbeafe',
2039
+ color: '#1e40af',
2040
+ border: '1px solid #93c5fd',
2041
+ borderRadius: 999,
2042
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
2043
+ font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
2044
+ }}
2045
+ >
2046
+ <span>Repo state changed — reload to sync?</span>
2047
+ <button
2048
+ type="button"
2049
+ onClick={() => { try { window.location.reload(); } catch {} }}
2050
+ style={{
2051
+ padding: '3px 10px',
2052
+ background: '#2563eb',
2053
+ color: '#fff',
2054
+ border: 'none',
2055
+ borderRadius: 4,
2056
+ font: '500 11px/1.2 system-ui',
2057
+ cursor: 'pointer',
2058
+ }}
2059
+ >Reload</button>
2060
+ <button
2061
+ type="button"
2062
+ onClick={() => setGitLifecycle(null)}
2063
+ style={{
2064
+ padding: '3px 10px',
2065
+ background: 'transparent',
2066
+ color: '#1e40af',
2067
+ border: '1px solid #93c5fd',
2068
+ borderRadius: 4,
2069
+ font: '500 11px/1.2 system-ui',
2070
+ cursor: 'pointer',
2071
+ }}
2072
+ >Dismiss</button>
2073
+ </div>
2074
+ )}
2003
2075
  <Sidebar
2004
2076
  groups={groups}
2005
2077
  activePath={activePath}