@1agh/maude 0.19.1 → 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 (105) 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 +17 -9
  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 +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -81,10 +81,22 @@ import {
81
81
  useState,
82
82
  } from 'react';
83
83
 
84
+ import {
85
+ AnimatePresence as _MotionAnimatePresence,
86
+ motion as _motionImpl,
87
+ useReducedMotion as _useReducedMotion,
88
+ } from 'motion/react';
89
+
84
90
  import { CanvasShell } from './canvas-shell.tsx';
91
+ import {
92
+ buildMoveArtboardsRecord,
93
+ diffLayoutPositions,
94
+ } from './commands/move-artboards-command.ts';
85
95
  import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
96
+ import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
86
97
  import { useSelectionSetOptional } from './use-selection-set.tsx';
87
98
  import { ToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
99
+ import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
88
100
 
89
101
  // ─────────────────────────────────────────────────────────────────────────────
90
102
  // Module constants
@@ -216,7 +228,7 @@ button.dc-artboard-label {
216
228
  display: block;
217
229
  width: 100%;
218
230
  }
219
- 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; }
220
232
  /* Active-artboard ring is in canvas-shell HALO_CSS (subtle 1 px tint). */
221
233
  /* Phase 4.2 — drag chrome. */
222
234
  .dc-canvas[data-active-tool="move"] .dc-artboard-label { cursor: grab; }
@@ -448,6 +460,26 @@ interface PersistedArtboardLayout {
448
460
  y: number;
449
461
  }
450
462
 
463
+ /**
464
+ * Phase 20 (DDR-050) — last timestamp at which THIS iframe wrote canvas
465
+ * meta. Read by `use-undo-stack.tsx` to discriminate self-echo fs:json
466
+ * events from genuine external edits.
467
+ */
468
+ declare global {
469
+ interface Window {
470
+ __maude_last_meta_self_write_at?: number;
471
+ }
472
+ }
473
+
474
+ const META_SELF_ECHO_WINDOW_MS = 500;
475
+
476
+ /** Recent enough to be our own PATCH echoing back through fs-watch. */
477
+ export function isMetaSelfEcho(now: number = Date.now()): boolean {
478
+ if (typeof window === 'undefined') return false;
479
+ const last = window.__maude_last_meta_self_write_at ?? 0;
480
+ return now - last < META_SELF_ECHO_WINDOW_MS;
481
+ }
482
+
451
483
  function patchCanvasMeta(patch: {
452
484
  viewport?: ViewportState;
453
485
  layout?: { artboards: ArtboardRect[] };
@@ -469,6 +501,10 @@ function patchCanvasMeta(patch: {
469
501
  })),
470
502
  };
471
503
  }
504
+ // Stamp the self-write timestamp BEFORE the fetch so the round-trip
505
+ // (PATCH → server write → fs:json broadcast → iframe message) lands
506
+ // safely inside the echo window even on a fast network.
507
+ window.__maude_last_meta_self_write_at = Date.now();
472
508
  fetch('/_api/canvas-meta', {
473
509
  method: 'PATCH',
474
510
  headers: { 'content-type': 'application/json' },
@@ -700,9 +736,22 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
700
736
  [applyViewport]
701
737
  );
702
738
 
739
+ // Forward-decl ref — fit / reset run animations that need `animateTo`, but
740
+ // `animateTo` is declared further down (depends on applyViewport). Resolve
741
+ // the ordering with a ref the animateTo callback writes to on definition.
742
+ const animateToRef = useRef<((t: ViewportState, d?: number) => void) | null>(null);
743
+
744
+ // T33 — programmatic zoom is eased. Direct user-driven gestures
745
+ // (wheel, pinch, drag-to-pan) still call `applyViewport` directly so
746
+ // input feel stays 1:1 with the cursor; only fit/reset/zoom-to-rect
747
+ // ease over 200 ms. `animateTo` honors `prefers-reduced-motion` and
748
+ // returns instantly under that media query.
749
+ const PROGRAMMATIC_EASE_MS = 200;
703
750
  const fit = useCallback(() => {
704
751
  const next = computeFitRef.current();
705
- applyViewport(next);
752
+ const a = animateToRef.current;
753
+ if (a) a(next, PROGRAMMATIC_EASE_MS);
754
+ else applyViewport(next);
706
755
  }, [applyViewport]);
707
756
 
708
757
  const reset = useCallback(() => {
@@ -713,8 +762,15 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
713
762
  }
714
763
  const cx = host.clientWidth / 2;
715
764
  const cy = host.clientHeight / 2;
716
- zoomAt(1 / vpRef.current.zoom, cx, cy);
717
- }, [hostRef, applyViewport, zoomAt]);
765
+ const v = vpRef.current;
766
+ // Target: same world-coord under cursor, zoom = 1.
767
+ const wx = (cx - v.x) / v.zoom;
768
+ const wy = (cy - v.y) / v.zoom;
769
+ const target: ViewportState = { x: cx - wx, y: cy - wy, zoom: 1 };
770
+ const a = animateToRef.current;
771
+ if (a) a(target, PROGRAMMATIC_EASE_MS);
772
+ else applyViewport(target);
773
+ }, [hostRef, applyViewport]);
718
774
 
719
775
  const zoomIn = useCallback(() => {
720
776
  const host = hostRef.current;
@@ -767,6 +823,10 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
767
823
  [applyViewport]
768
824
  );
769
825
 
826
+ // Keep the ref pointed at the latest `animateTo` so the earlier-declared
827
+ // fit / reset callbacks can call it without circular dependency.
828
+ animateToRef.current = animateTo;
829
+
770
830
  const jumpTo = useCallback(
771
831
  (rect: ArtboardRect) => {
772
832
  const host = hostRef.current;
@@ -844,7 +904,13 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
844
904
  // without a physical Ctrl press — so the same branch covers both
845
905
  // Ctrl+wheel (mouse) and pinch-zoom (trackpad).
846
906
  if (e.ctrlKey || e.metaKey) {
847
- const factor = Math.exp(-e.deltaY * WHEEL_ZOOM_K);
907
+ // T32 clamp deltaY into [-50, 50] before the exp() to bring
908
+ // trackpad-pinch (small per-frame delta) and mouse-wheel (one
909
+ // notch = ±100) onto the same perceived-speed curve. Mouse-wheel
910
+ // users still get smooth zoom (clamped notches accumulate at the
911
+ // same exp rate), trackpad-pinch users no longer outpace them.
912
+ const clamped = Math.max(-50, Math.min(50, e.deltaY));
913
+ const factor = Math.exp(-clamped * WHEEL_ZOOM_K);
848
914
  zoomAt(factor, cx, cy);
849
915
  return;
850
916
  }
@@ -1052,11 +1118,24 @@ export function useViewportControllerContext(): ViewportControllerHandle | null
1052
1118
  // drag). A single source-of-truth: only one drag can be active at a time, so
1053
1119
  // the bus holds a single DragState. Each DCArtboard's hook writes here when
1054
1120
  // non-idle and resets to idle on release.
1121
+ /** Optional metadata accompanying a position commit. */
1122
+ export interface CommitPositionsOptions {
1123
+ /**
1124
+ * HUD / undo-stack label override. Default = `"move N artboard(s)"`.
1125
+ * Distribute / align gestures pass their own label so undo HUD reads
1126
+ * `"Undo: equal-space 4 artboards"` instead of `"Undo: move 4 artboards"`.
1127
+ */
1128
+ label?: string;
1129
+ }
1130
+
1055
1131
  interface DragStateBus {
1056
1132
  current: DragState;
1057
1133
  setCurrent: (s: DragState) => void;
1058
1134
  /** Commit drag positions — DesignCanvas wires this to patchCanvasMeta. */
1059
- commitPositions: (moved: { id: string; x: number; y: number }[]) => void;
1135
+ commitPositions: (
1136
+ moved: { id: string; x: number; y: number }[],
1137
+ opts?: CommitPositionsOptions
1138
+ ) => void;
1060
1139
  }
1061
1140
 
1062
1141
  const DragStateContext = createContext<DragStateBus | null>(null);
@@ -1085,11 +1164,29 @@ interface DesignCanvasProps {
1085
1164
  * via `useToolModeOptional` (hand-mode bare-drag pan).
1086
1165
  */
1087
1166
  export function DesignCanvas(props: DesignCanvasProps) {
1088
- return (
1167
+ // Phase 20 — per-canvas undo/redo stack (DDR-050 rev 2). The provider
1168
+ // wraps both DesignCanvasInner (so artboard commits push records) AND
1169
+ // the CanvasShell tree (so input-router Cmd+Z / Cmd+Shift+Z + the HUD
1170
+ // share the same context). Stack state is keyed by canvas file path in
1171
+ // `window.top.__maude_undo_stacks` so it survives canvas switches —
1172
+ // close Foo.tsx, open Bar.tsx, come back to Foo.tsx → history intact.
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 = (
1089
1181
  <ToolProvider>
1090
1182
  <DesignCanvasInner {...props} />
1091
1183
  </ToolProvider>
1092
1184
  );
1185
+ return (
1186
+ <UndoStackProvider canvasFile={canvasFile}>
1187
+ {collabSlug ? <CollabProvider slug={collabSlug}>{inner}</CollabProvider> : inner}
1188
+ </UndoStackProvider>
1189
+ );
1093
1190
  }
1094
1191
  DesignCanvas.displayName = 'DesignCanvas';
1095
1192
 
@@ -1141,6 +1238,22 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1141
1238
  setArtboards(initialArtboards());
1142
1239
  }, [initialArtboards]);
1143
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
+
1144
1257
  // Stable refs so the controller's callbacks always see the latest values.
1145
1258
  const artboardsRef = useRef(artboards);
1146
1259
  artboardsRef.current = artboards;
@@ -1239,20 +1352,56 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1239
1352
  // hook is non-idle; SnapGuideOverlay (in canvas-shell) reads guides.
1240
1353
  const [dragCurrent, setDragCurrent] = useState<DragState>({ kind: 'idle' });
1241
1354
 
1242
- const commitArtboardPositions = useCallback((moved: { id: string; x: number; y: number }[]) => {
1243
- const movedById = new Map(moved.map((m) => [m.id, m]));
1244
- const next = artboardsRef.current.map((r) => {
1245
- const m = movedById.get(r.id);
1246
- if (m) return { ...r, x: m.x, y: m.y };
1247
- return r;
1248
- });
1249
- // Optimistic local update — DOM reflects the new position the moment
1250
- // the drag drops, no iframe reload required. The PATCH below catches
1251
- // the server up; if it fails we already logged it via `patchCanvasMeta`.
1252
- setArtboards(next);
1253
- patchCanvasMeta({ layout: { artboards: next } });
1355
+ const undoStack = useUndoStackOptional();
1356
+ const undoSinks = useUndoSinks();
1357
+ // Stable ref so the commit callback (memoized once, on mount) always reads
1358
+ // the latest stack value without re-creating the callback on every render.
1359
+ const undoStackRef = useRef(undoStack);
1360
+ undoStackRef.current = undoStack;
1361
+
1362
+ /**
1363
+ * Applies a full artboard layout: optimistic local React state update +
1364
+ * server PATCH. Used as the `layoutPatchFn` sink registered with the
1365
+ * undo stack — both the initial commit (do) and every undo/redo replay
1366
+ * route through here, so React state always tracks the server.
1367
+ */
1368
+ const applyArtboardLayout = useCallback((layout: unknown) => {
1369
+ setArtboards(layout as ArtboardRect[]);
1370
+ patchCanvasMeta({ layout: { artboards: layout as ArtboardRect[] } });
1254
1371
  }, []);
1255
1372
 
1373
+ // Register the layout patch sink with the undo provider so the rebuilt
1374
+ // MoveArtboardsCommand (after a canvas switch + return) can apply layouts
1375
+ // through THIS iframe's React state, not the gone iframe's stale closures.
1376
+ useEffect(() => {
1377
+ undoSinks.setSink('layoutPatchFn', applyArtboardLayout);
1378
+ return () => undoSinks.setSink('layoutPatchFn', undefined);
1379
+ }, [undoSinks, applyArtboardLayout]);
1380
+
1381
+ const commitArtboardPositions = useCallback(
1382
+ (moved: { id: string; x: number; y: number }[], opts?: CommitPositionsOptions) => {
1383
+ const movedById = new Map(moved.map((m) => [m.id, m]));
1384
+ const before = artboardsRef.current;
1385
+ const next = before.map((r) => {
1386
+ const m = movedById.get(r.id);
1387
+ if (m) return { ...r, x: m.x, y: m.y };
1388
+ return r;
1389
+ });
1390
+ // Phase 20 — skip pushing no-op drags (click-without-movement). The
1391
+ // user got back what they had, no edit happened, undo would do nothing.
1392
+ if (!diffLayoutPositions(before, next)) return;
1393
+ const record = buildMoveArtboardsRecord({
1394
+ before: before.map((r) => ({ id: r.id, x: r.x, y: r.y, w: r.w, h: r.h })),
1395
+ after: next.map((r) => ({ id: r.id, x: r.x, y: r.y, w: r.w, h: r.h })),
1396
+ ...(opts?.label ? { label: opts.label } : {}),
1397
+ });
1398
+ // push() invokes the rebuilt cmd.do() = applyArtboardLayout(next) BEFORE
1399
+ // updating the stack, so the optimistic local + PATCH flow is preserved.
1400
+ void undoStackRef.current.push(record);
1401
+ },
1402
+ []
1403
+ );
1404
+
1256
1405
  const dragBus = useMemo<DragStateBus>(
1257
1406
  () => ({
1258
1407
  current: dragCurrent,
@@ -1348,7 +1497,7 @@ export function DCArtboard({
1348
1497
  children: ReactNode;
1349
1498
  }) {
1350
1499
  const ctx = useWorldContext();
1351
- const controller = useViewportControllerContext();
1500
+ const _controller = useViewportControllerContext();
1352
1501
  const toolMode = useToolModeOptional();
1353
1502
  const selSet = useSelectionSetOptional();
1354
1503
  const dragBus = useDragStateContext();
@@ -1394,9 +1543,11 @@ export function DCArtboard({
1394
1543
  );
1395
1544
  }
1396
1545
  const isActive = ctx.activeArtboardId === id;
1397
- const onFocus = () => {
1398
- if (controller) controller.jumpTo(rect);
1399
- };
1546
+ // G2v2 earlier the label single-click called `controller.jumpTo(rect)`,
1547
+ // auto-zooming on every click. Per post-Wave-3.5 feedback this was
1548
+ // surprising. The label button stays for a11y (focus + screen-reader
1549
+ // label) but no longer mutates the viewport. Cmd+1 + the zoom HUD still
1550
+ // expose the manual zoom path.
1400
1551
 
1401
1552
  // Am I involved in the current drag (as leader or follower)?
1402
1553
  const busDrag = dragBus?.current;
@@ -1431,12 +1582,7 @@ export function DCArtboard({
1431
1582
  style={{ left: liveX, top: liveY, width: rect.w, height: rect.h }}
1432
1583
  {...handleProps}
1433
1584
  >
1434
- <button
1435
- type="button"
1436
- className="dc-artboard-label sku"
1437
- onClick={onFocus}
1438
- aria-label={`Focus artboard ${label}`}
1439
- >
1585
+ <button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
1440
1586
  {label}
1441
1587
  </button>
1442
1588
  <div className="dc-artboard-body">{children}</div>
@@ -1616,8 +1762,8 @@ const OVERLAY_CSS = `
1616
1762
  outline-only. Reads from a glance as "what slice of the world you're on". */
1617
1763
  .dc-mm-vp {
1618
1764
  position: absolute;
1619
- background: color-mix(in oklab, var(--accent, #d63b1f) 12%, transparent);
1620
- 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);
1621
1767
  border-radius: 1px;
1622
1768
  pointer-events: none;
1623
1769
  }
@@ -1653,7 +1799,7 @@ const OVERLAY_CSS = `
1653
1799
  }
1654
1800
  .dc-zoom-tb button:last-child { border-right: 0; }
1655
1801
  .dc-zoom-tb button:hover { background: color-mix(in oklab, var(--fg-0, #1c1917) 5%, transparent); }
1656
- .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; }
1657
1803
  .dc-zoom-tb-pct { font-variant-numeric: tabular-nums; min-width: 52px; }
1658
1804
  `.trim();
1659
1805
 
@@ -2088,3 +2234,359 @@ export function useArtboardBounds(ref: RefObject<HTMLElement | null>): {
2088
2234
  // Re-export `useRef` so `useArtboardBounds` consumers can keep a single
2089
2235
  // import line from `@maude/canvas-lib`.
2090
2236
  export { useRef };
2237
+
2238
+ // ─────────────────────────────────────────────────────────────────────────────
2239
+ // Motion subsystem (Phase 3.7 / DDR-049 — Motion One is the canonical runtime)
2240
+ //
2241
+ // These helpers are tree-shakeable. A canvas that does not import any of them
2242
+ // pays no bundle cost (motion/react is externalised via RUNTIME_PACKAGES, so
2243
+ // even when imported the byte cost lives in a single shared runtime bundle,
2244
+ // not per-canvas).
2245
+ //
2246
+ // Roles map 1:1 to the 8 motion-vocabulary names enforced by motion-critic +
2247
+ // design-system-keeper. Each role binds to a DS duration + easing token from
2248
+ // colors_and_type.css; useMotionTokens() reads the live CSS custom property
2249
+ // values so the binding survives token edits without rebuilding canvas-lib.
2250
+ //
2251
+ // Bounded-geometry guarantee — every <MotionDemo> root sets `overflow: hidden`
2252
+ // in inline style. That defends against sparkle-on-tile overflow regardless of
2253
+ // the host class chrome. See SUB-AGENT-PROMPTS.md → ANIMATION SAFETY.
2254
+ // ─────────────────────────────────────────────────────────────────────────────
2255
+
2256
+ export type MotionRole =
2257
+ | 'flip'
2258
+ | 'panel'
2259
+ | 'route'
2260
+ | 'soft'
2261
+ | 'spring'
2262
+ | 'scroll'
2263
+ | 'drag'
2264
+ | 'presence';
2265
+
2266
+ export type MotionLoop = 'always' | 'hover' | 'once';
2267
+
2268
+ interface RoleConfig {
2269
+ durationToken: string;
2270
+ easingToken: string;
2271
+ keyframes: Record<string, number[]>;
2272
+ fallbackMs: number;
2273
+ }
2274
+
2275
+ export const MOTION_ROLE_DEFAULTS: Record<MotionRole, RoleConfig> = {
2276
+ flip: {
2277
+ durationToken: '--dur-flip',
2278
+ easingToken: '--ease-out',
2279
+ keyframes: { y: [0, -12, 0] },
2280
+ fallbackMs: 220,
2281
+ },
2282
+ panel: {
2283
+ durationToken: '--dur-panel',
2284
+ easingToken: '--ease-in-out',
2285
+ keyframes: { x: [-80, 0, -80] },
2286
+ fallbackMs: 320,
2287
+ },
2288
+ route: {
2289
+ durationToken: '--dur-route',
2290
+ easingToken: '--ease-out',
2291
+ keyframes: { opacity: [0, 1, 0], scale: [0.92, 1, 0.92] },
2292
+ fallbackMs: 480,
2293
+ },
2294
+ soft: {
2295
+ durationToken: '--dur-soft',
2296
+ easingToken: '--ease-out',
2297
+ keyframes: { opacity: [0, 1, 0] },
2298
+ fallbackMs: 160,
2299
+ },
2300
+ spring: {
2301
+ durationToken: '--dur-panel',
2302
+ easingToken: 'spring',
2303
+ keyframes: { y: [0, -16, 0] },
2304
+ fallbackMs: 320,
2305
+ },
2306
+ scroll: {
2307
+ durationToken: '--dur-route',
2308
+ easingToken: '--ease-in-out',
2309
+ keyframes: { x: [0, 24, 0] },
2310
+ fallbackMs: 480,
2311
+ },
2312
+ drag: {
2313
+ durationToken: '--dur-flip',
2314
+ easingToken: '--ease-out',
2315
+ keyframes: { rotate: [0, 4, 0] },
2316
+ fallbackMs: 220,
2317
+ },
2318
+ presence: {
2319
+ durationToken: '--dur-soft',
2320
+ easingToken: '--ease-out',
2321
+ keyframes: { opacity: [0, 1], scale: [0.9, 1] },
2322
+ fallbackMs: 160,
2323
+ },
2324
+ };
2325
+
2326
+ /**
2327
+ * Reads --dur-* + --ease-* CSS custom properties from documentElement and
2328
+ * returns a plain map suitable for plugging into motion/react's transition
2329
+ * config. ms values parsed to numbers; easing tokens returned as strings (the
2330
+ * raw token value, e.g. "cubic-bezier(0, 0, 0.2, 1)" — motion/react accepts
2331
+ * the string form).
2332
+ */
2333
+ export function useMotionTokens(): {
2334
+ durations: Record<string, number>;
2335
+ easings: Record<string, string>;
2336
+ } {
2337
+ const [snap, setSnap] = useState(() => readMotionTokensOnce());
2338
+ useEffect(() => {
2339
+ setSnap(readMotionTokensOnce());
2340
+ if (typeof MutationObserver === 'undefined') return;
2341
+ const obs = new MutationObserver(() => setSnap(readMotionTokensOnce()));
2342
+ obs.observe(document.documentElement, {
2343
+ attributes: true,
2344
+ attributeFilter: ['data-theme', 'data-reduced-motion'],
2345
+ });
2346
+ return () => obs.disconnect();
2347
+ }, []);
2348
+ return snap;
2349
+ }
2350
+
2351
+ function readMotionTokensOnce(): {
2352
+ durations: Record<string, number>;
2353
+ easings: Record<string, string>;
2354
+ } {
2355
+ if (typeof window === 'undefined' || typeof getComputedStyle === 'undefined') {
2356
+ return { durations: {}, easings: {} };
2357
+ }
2358
+ const cs = getComputedStyle(document.documentElement);
2359
+ const durations: Record<string, number> = {};
2360
+ const easings: Record<string, string> = {};
2361
+ const durKeys = ['--dur-flip', '--dur-panel', '--dur-route', '--dur-soft'];
2362
+ const easeKeys = ['--ease-out', '--ease-in', '--ease-in-out'];
2363
+ for (const k of durKeys) {
2364
+ const raw = cs.getPropertyValue(k).trim();
2365
+ if (!raw) continue;
2366
+ const n = Number.parseFloat(raw);
2367
+ if (Number.isFinite(n)) {
2368
+ durations[k] = raw.endsWith('s') && !raw.endsWith('ms') ? n * 1000 : n;
2369
+ }
2370
+ }
2371
+ for (const k of easeKeys) {
2372
+ const raw = cs.getPropertyValue(k).trim();
2373
+ if (raw) easings[k] = raw;
2374
+ }
2375
+ return { durations, easings };
2376
+ }
2377
+
2378
+ /**
2379
+ * Maps a DS easing token name to the value motion/react's `transition.ease`
2380
+ * accepts. Returns the live CSS string when readable, otherwise a sane default
2381
+ * matching Material's "standard" curve.
2382
+ */
2383
+ export function easingFromToken(
2384
+ token: string,
2385
+ easings: Record<string, string>
2386
+ ): string | undefined {
2387
+ const live = easings[token];
2388
+ if (live) return live;
2389
+ if (token === '--ease-out') return 'cubic-bezier(0, 0, 0.2, 1)';
2390
+ if (token === '--ease-in') return 'cubic-bezier(0.4, 0, 1, 1)';
2391
+ if (token === '--ease-in-out') return 'cubic-bezier(0.4, 0, 0.2, 1)';
2392
+ return undefined;
2393
+ }
2394
+
2395
+ interface MotionDemoProps {
2396
+ role: MotionRole;
2397
+ loop?: MotionLoop;
2398
+ children?: ReactNode;
2399
+ small?: boolean;
2400
+ className?: string;
2401
+ label?: string;
2402
+ }
2403
+
2404
+ /**
2405
+ * The foundational motion building block. Wraps motion/react's animated <div>
2406
+ * with token-bound duration + easing + reduced-motion short-circuit.
2407
+ *
2408
+ * Default loop="always" so initial paint shows motion — the "looks dead at
2409
+ * rest" failure mode is the regression Phase 3.7 exists to prevent.
2410
+ */
2411
+ export function MotionDemo({
2412
+ role,
2413
+ loop = 'always',
2414
+ children,
2415
+ small = false,
2416
+ className,
2417
+ label,
2418
+ }: MotionDemoProps) {
2419
+ const cfg = MOTION_ROLE_DEFAULTS[role];
2420
+ const tokens = useMotionTokens();
2421
+ const reduced = _useReducedMotion();
2422
+ const durationMs = tokens.durations[cfg.durationToken] ?? cfg.fallbackMs;
2423
+ const isSpring = cfg.easingToken === 'spring';
2424
+ const ease = isSpring ? undefined : easingFromToken(cfg.easingToken, tokens.easings);
2425
+ const repeat = reduced || loop === 'once' ? 0 : Number.POSITIVE_INFINITY;
2426
+ const repeatType: 'reverse' | 'loop' = loop === 'always' ? 'reverse' : 'loop';
2427
+ const animate = reduced ? undefined : cfg.keyframes;
2428
+
2429
+ return (
2430
+ <div
2431
+ className={`motion-demo${className ? ` ${className}` : ''}`}
2432
+ data-role={role}
2433
+ data-small={small ? 'true' : undefined}
2434
+ style={{ overflow: 'hidden', position: 'relative' }}
2435
+ >
2436
+ <_motionImpl.div
2437
+ animate={animate}
2438
+ transition={{
2439
+ duration: durationMs / 1000,
2440
+ ease,
2441
+ type: isSpring ? 'spring' : 'tween',
2442
+ repeat,
2443
+ repeatType,
2444
+ }}
2445
+ className="motion-demo__target"
2446
+ aria-label={label}
2447
+ style={small ? { width: 32, height: 32 } : undefined}
2448
+ >
2449
+ {children ?? <div className="motion-demo__chip" />}
2450
+ </_motionImpl.div>
2451
+ </div>
2452
+ );
2453
+ }
2454
+
2455
+ interface MotionTrackProps {
2456
+ children: ReactNode;
2457
+ staggerMs?: number;
2458
+ className?: string;
2459
+ }
2460
+
2461
+ /**
2462
+ * Row container with CSS animation-delay stagger between children.
2463
+ */
2464
+ export function MotionTrack({ children, staggerMs = 40, className }: MotionTrackProps) {
2465
+ const items = Array.isArray(children) ? children : [children];
2466
+ return (
2467
+ <div
2468
+ className={`motion-track${className ? ` ${className}` : ''}`}
2469
+ style={{ display: 'flex', gap: 12, alignItems: 'center' }}
2470
+ >
2471
+ {items.map((c, i) => (
2472
+ // biome-ignore lint/suspicious/noArrayIndexKey: stagger row is index-positional by design; no reorder/insertion semantics
2473
+ <div key={i} style={{ animationDelay: `${i * staggerMs}ms` }}>
2474
+ {c}
2475
+ </div>
2476
+ ))}
2477
+ </div>
2478
+ );
2479
+ }
2480
+
2481
+ interface TokenPlaybackProps {
2482
+ duration: string;
2483
+ easing?: string;
2484
+ label?: string;
2485
+ keyframes?: Record<string, number[]>;
2486
+ }
2487
+
2488
+ /**
2489
+ * Click-to-fire single-shot replay chip. Used in the motion specimen so
2490
+ * reviewers can probe a single token without hovering a card.
2491
+ */
2492
+ export function TokenPlayback({
2493
+ duration,
2494
+ easing = '--ease-out',
2495
+ label,
2496
+ keyframes = { y: [0, -8, 0] },
2497
+ }: TokenPlaybackProps) {
2498
+ const tokens = useMotionTokens();
2499
+ const reduced = _useReducedMotion();
2500
+ const durationMs = tokens.durations[duration] ?? 220;
2501
+ const ease = easing === 'spring' ? undefined : easingFromToken(easing, tokens.easings);
2502
+ const [tick, setTick] = useState(0);
2503
+ const fire = useCallback(() => {
2504
+ if (!reduced) setTick((n) => n + 1);
2505
+ }, [reduced]);
2506
+ return (
2507
+ <button
2508
+ type="button"
2509
+ className="token-playback"
2510
+ onClick={fire}
2511
+ style={{
2512
+ display: 'inline-flex',
2513
+ alignItems: 'center',
2514
+ gap: 8,
2515
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
2516
+ fontSize: 12,
2517
+ padding: '6px 10px',
2518
+ border: '1px solid var(--border-1, currentColor)',
2519
+ borderRadius: 4,
2520
+ background: 'transparent',
2521
+ color: 'inherit',
2522
+ cursor: 'pointer',
2523
+ }}
2524
+ >
2525
+ <span style={{ opacity: 0.6 }}>{label ?? duration}</span>
2526
+ <_motionImpl.span
2527
+ key={tick}
2528
+ animate={tick === 0 ? undefined : keyframes}
2529
+ transition={{
2530
+ duration: durationMs / 1000,
2531
+ ease,
2532
+ type: easing === 'spring' ? 'spring' : 'tween',
2533
+ }}
2534
+ style={{
2535
+ display: 'inline-block',
2536
+ width: 8,
2537
+ height: 8,
2538
+ background: 'var(--accent, currentColor)',
2539
+ borderRadius: '50%',
2540
+ }}
2541
+ />
2542
+ <span style={{ fontVariantNumeric: 'tabular-nums' }}>{durationMs}ms</span>
2543
+ </button>
2544
+ );
2545
+ }
2546
+
2547
+ /**
2548
+ * Chrome toggle for the motion specimen — flips data-reduced-motion="true"
2549
+ * on <html> so reviewers can eyeball both branches without OS settings.
2550
+ * Inspection aid, never a replacement for prefers-reduced-motion.
2551
+ */
2552
+ export function ReducedMotionToggle() {
2553
+ const [on, setOn] = useState(false);
2554
+ useEffect(() => {
2555
+ const el = document.documentElement;
2556
+ const initial = el.getAttribute('data-reduced-motion') === 'true';
2557
+ setOn(initial);
2558
+ }, []);
2559
+ const toggle = useCallback(() => {
2560
+ const el = document.documentElement;
2561
+ const next = !on;
2562
+ if (next) el.setAttribute('data-reduced-motion', 'true');
2563
+ else el.removeAttribute('data-reduced-motion');
2564
+ setOn(next);
2565
+ }, [on]);
2566
+ return (
2567
+ <button
2568
+ type="button"
2569
+ className="reduced-motion-toggle"
2570
+ onClick={toggle}
2571
+ aria-pressed={on}
2572
+ style={{
2573
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
2574
+ fontSize: 11,
2575
+ padding: '4px 8px',
2576
+ border: '1px solid var(--border-1, currentColor)',
2577
+ borderRadius: 3,
2578
+ background: on ? 'var(--accent, currentColor)' : 'transparent',
2579
+ color: on ? 'var(--bg-0, white)' : 'inherit',
2580
+ cursor: 'pointer',
2581
+ }}
2582
+ >
2583
+ reduced-motion: {on ? 'on' : 'off'}
2584
+ </button>
2585
+ );
2586
+ }
2587
+
2588
+ export {
2589
+ _motionImpl as motion,
2590
+ _useReducedMotion as useReducedMotion,
2591
+ _MotionAnimatePresence as AnimatePresence,
2592
+ };