@1agh/maude 0.19.0 → 0.20.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 (66) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
  3. package/plugins/design/dev-server/annotations-layer.tsx +343 -112
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
  6. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  7. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +691 -119
  9. package/plugins/design/dev-server/canvas-shell.tsx +818 -42
  10. package/plugins/design/dev-server/client/app.jsx +173 -52
  11. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  12. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  13. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  14. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  15. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  16. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  17. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  18. package/plugins/design/dev-server/config.schema.json +31 -5
  19. package/plugins/design/dev-server/context-menu.tsx +3 -3
  20. package/plugins/design/dev-server/context.ts +37 -3
  21. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  22. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  23. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  24. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  25. package/plugins/design/dev-server/dist/styles.css +38 -2
  26. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  27. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  28. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  29. package/plugins/design/dev-server/handoff.ts +24 -0
  30. package/plugins/design/dev-server/http.ts +41 -2
  31. package/plugins/design/dev-server/input-router.tsx +74 -10
  32. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  33. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  34. package/plugins/design/dev-server/server.mjs +84 -18
  35. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  36. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  37. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  38. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  39. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  40. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  41. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  42. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  43. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  44. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  45. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  46. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  47. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  48. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  49. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  50. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  51. package/plugins/design/dev-server/undo-stack.ts +240 -0
  52. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  53. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  54. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  55. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  56. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  57. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
  58. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  59. package/plugins/design/templates/_shell.html +17 -6
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  61. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  62. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  63. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  64. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  65. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  66. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -66,6 +66,7 @@
66
66
 
67
67
  import {
68
68
  type CSSProperties,
69
+ Fragment,
69
70
  type ReactNode,
70
71
  type PointerEvent as ReactPointerEvent,
71
72
  type RefObject,
@@ -80,10 +81,21 @@ import {
80
81
  useState,
81
82
  } from 'react';
82
83
 
84
+ import {
85
+ AnimatePresence as _MotionAnimatePresence,
86
+ motion as _motionImpl,
87
+ useReducedMotion as _useReducedMotion,
88
+ } from 'motion/react';
89
+
83
90
  import { CanvasShell } from './canvas-shell.tsx';
91
+ import {
92
+ buildMoveArtboardsRecord,
93
+ diffLayoutPositions,
94
+ } from './commands/move-artboards-command.ts';
84
95
  import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
85
96
  import { useSelectionSetOptional } from './use-selection-set.tsx';
86
97
  import { ToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
98
+ import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
87
99
 
88
100
  // ─────────────────────────────────────────────────────────────────────────────
89
101
  // Module constants
@@ -108,12 +120,63 @@ const ENGINE_CSS = `
108
120
  inset: 0;
109
121
  overflow: hidden;
110
122
  outline: none;
123
+ /* DDR-046 — snap-layer magenta is distinct from --accent so the snap chrome
124
+ never visually melts into the selection halo during a drag-snap gesture.
125
+ OKLCH default approximates FigJam magenta in the project's color space. */
126
+ --guide-magenta: oklch(62% 0.28 350);
111
127
  background-color: var(--bg-1, #f4f1ea);
112
128
  background-image:
113
129
  linear-gradient(var(--border-subtle, rgba(0,0,0,0.08)) 1px, transparent 1px),
114
130
  linear-gradient(90deg, var(--border-subtle, rgba(0,0,0,0.08)) 1px, transparent 1px);
115
131
  background-size: 24px 24px;
116
132
  }
133
+ /* DDR-046 — Snap guides. Sibling kind = confident magenta + glow + distance
134
+ pill. Grid kind = lighter gray fallback, no pill. Width 2 px (up from 1 px)
135
+ so the line stays readable at zoom < 0.8. */
136
+ .dc-snap-guide {
137
+ position: fixed;
138
+ pointer-events: none;
139
+ z-index: 6;
140
+ animation: dc-snap-spawn 80ms cubic-bezier(0.2, 0.8, 0.2, 1);
141
+ transform-origin: center;
142
+ }
143
+ .dc-snap-guide--sibling {
144
+ background: var(--guide-magenta, oklch(62% 0.28 350));
145
+ box-shadow: 0 0 4px color-mix(in oklab, var(--guide-magenta, oklch(62% 0.28 350)) 35%, transparent);
146
+ }
147
+ .dc-snap-guide--grid {
148
+ background: color-mix(in oklab, var(--fg-3, var(--fg-1, #4a3f30)) 40%, transparent);
149
+ }
150
+ .dc-snap-pill {
151
+ position: fixed;
152
+ pointer-events: none;
153
+ z-index: 7;
154
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
155
+ font-size: 11px;
156
+ font-weight: 600;
157
+ line-height: 1;
158
+ color: #fff;
159
+ background: var(--guide-magenta, oklch(62% 0.28 350));
160
+ padding: 3px 6px;
161
+ border-radius: 3px;
162
+ white-space: nowrap;
163
+ font-variant-numeric: tabular-nums;
164
+ animation: dc-snap-pill-spawn 80ms cubic-bezier(0.2, 0.8, 0.2, 1);
165
+ }
166
+ @keyframes dc-snap-spawn {
167
+ from { opacity: 0; transform: scaleY(0.92); }
168
+ to { opacity: 1; transform: scaleY(1); }
169
+ }
170
+ @keyframes dc-snap-pill-spawn {
171
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.88); }
172
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
173
+ }
174
+ @media (prefers-reduced-motion: reduce) {
175
+ .dc-snap-guide,
176
+ .dc-snap-pill {
177
+ animation: none !important;
178
+ }
179
+ }
117
180
  .dc-canvas:focus { outline: none; }
118
181
  .dc-world {
119
182
  position: absolute;
@@ -129,8 +192,11 @@ const ENGINE_CSS = `
129
192
  .dc-canvas .dc-artboard {
130
193
  background: var(--bg-0, #ffffff);
131
194
  color: var(--fg-0, #2a2520);
132
- border: 1px solid var(--fg-0, #2a2520);
133
- box-shadow: 6px 6px 0 var(--fg-0, #2a2520);
195
+ /* Quiet frame chrome — FigJam Section / Figma Frame canonical. The Memphis
196
+ hard-shadow is the Maude brand on USER CONTENT inside artboards; the frame
197
+ itself stays calm so user content reads as loud. */
198
+ border: 1px solid color-mix(in oklab, var(--fg-0, #2a2520) 22%, transparent);
199
+ border-radius: 2px;
134
200
  display: flex;
135
201
  flex-direction: column;
136
202
  overflow: hidden;
@@ -166,25 +232,15 @@ button.dc-artboard-label:focus-visible { outline: 2px solid var(--accent, #d63b1
166
232
  /* Phase 4.2 — drag chrome. */
167
233
  .dc-canvas[data-active-tool="move"] .dc-artboard-label { cursor: grab; }
168
234
  .dc-canvas[data-active-tool="move"] .dc-artboard-label:active { cursor: grabbing; }
169
- .dc-canvas .dc-artboard.dc-dragging { opacity: 0.3; }
170
- .dc-canvas .dc-artboard-ghost {
171
- position: absolute;
172
- pointer-events: none;
173
- opacity: 0.5;
174
- background: var(--bg-0, #ffffff);
175
- border: 1px solid var(--fg-0, #2a2520);
176
- box-shadow: 6px 6px 0 var(--fg-0, #2a2520);
177
- z-index: 4;
178
- }
179
- .dc-canvas .dc-artboard-ghost-label {
180
- background: var(--bg-2, #e8e3d8);
181
- border-bottom: 1px solid var(--fg-0, #2a2520);
182
- padding: 6px 14px;
183
- font-size: 10px;
184
- letter-spacing: 0.06em;
185
- text-transform: uppercase;
186
- color: var(--fg-1, #4a3f30);
187
- font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
235
+ /* Post-Wave-2 direct artboard drag. The article itself updates its
236
+ inline left/top during drag (no ghost placeholder, no opacity fade on
237
+ the original). Compositor handles the pixel movement; halo + group
238
+ bbox naturally follow via getBoundingClientRect on the article. */
239
+ .dc-canvas .dc-artboard.dc-dragging {
240
+ z-index: 5;
241
+ /* Keep the cursor consistent with the label's grabbing affordance even
242
+ when the pointer drifts off the label strip during the drag. */
243
+ cursor: grabbing;
188
244
  }
189
245
  `.trim();
190
246
 
@@ -237,6 +293,12 @@ function useWorldContext(): WorldContextValue | null {
237
293
  return useContext(WorldContext);
238
294
  }
239
295
 
296
+ /** Read-only access to the artboard list + viewport state. Used by overlays
297
+ * that need to operate on artboards directly (distribute, marquee, etc.). */
298
+ export function useArtboardsContext(): WorldContextValue | null {
299
+ return useContext(WorldContext);
300
+ }
301
+
240
302
  // Phase 5.1: annotations-layer needs the world `<div>` to portal a stroke SVG
241
303
  // inside it (so CSS zoom + translate apply natively, no per-frame projection
242
304
  // math). Expose only the ref — the rest of WorldContextValue stays internal.
@@ -397,6 +459,26 @@ interface PersistedArtboardLayout {
397
459
  y: number;
398
460
  }
399
461
 
462
+ /**
463
+ * Phase 20 (DDR-050) — last timestamp at which THIS iframe wrote canvas
464
+ * meta. Read by `use-undo-stack.tsx` to discriminate self-echo fs:json
465
+ * events from genuine external edits.
466
+ */
467
+ declare global {
468
+ interface Window {
469
+ __maude_last_meta_self_write_at?: number;
470
+ }
471
+ }
472
+
473
+ const META_SELF_ECHO_WINDOW_MS = 500;
474
+
475
+ /** Recent enough to be our own PATCH echoing back through fs-watch. */
476
+ export function isMetaSelfEcho(now: number = Date.now()): boolean {
477
+ if (typeof window === 'undefined') return false;
478
+ const last = window.__maude_last_meta_self_write_at ?? 0;
479
+ return now - last < META_SELF_ECHO_WINDOW_MS;
480
+ }
481
+
400
482
  function patchCanvasMeta(patch: {
401
483
  viewport?: ViewportState;
402
484
  layout?: { artboards: ArtboardRect[] };
@@ -418,6 +500,10 @@ function patchCanvasMeta(patch: {
418
500
  })),
419
501
  };
420
502
  }
503
+ // Stamp the self-write timestamp BEFORE the fetch so the round-trip
504
+ // (PATCH → server write → fs:json broadcast → iframe message) lands
505
+ // safely inside the echo window even on a fast network.
506
+ window.__maude_last_meta_self_write_at = Date.now();
421
507
  fetch('/_api/canvas-meta', {
422
508
  method: 'PATCH',
423
509
  headers: { 'content-type': 'application/json' },
@@ -649,9 +735,22 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
649
735
  [applyViewport]
650
736
  );
651
737
 
738
+ // Forward-decl ref — fit / reset run animations that need `animateTo`, but
739
+ // `animateTo` is declared further down (depends on applyViewport). Resolve
740
+ // the ordering with a ref the animateTo callback writes to on definition.
741
+ const animateToRef = useRef<((t: ViewportState, d?: number) => void) | null>(null);
742
+
743
+ // T33 — programmatic zoom is eased. Direct user-driven gestures
744
+ // (wheel, pinch, drag-to-pan) still call `applyViewport` directly so
745
+ // input feel stays 1:1 with the cursor; only fit/reset/zoom-to-rect
746
+ // ease over 200 ms. `animateTo` honors `prefers-reduced-motion` and
747
+ // returns instantly under that media query.
748
+ const PROGRAMMATIC_EASE_MS = 200;
652
749
  const fit = useCallback(() => {
653
750
  const next = computeFitRef.current();
654
- applyViewport(next);
751
+ const a = animateToRef.current;
752
+ if (a) a(next, PROGRAMMATIC_EASE_MS);
753
+ else applyViewport(next);
655
754
  }, [applyViewport]);
656
755
 
657
756
  const reset = useCallback(() => {
@@ -662,8 +761,15 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
662
761
  }
663
762
  const cx = host.clientWidth / 2;
664
763
  const cy = host.clientHeight / 2;
665
- zoomAt(1 / vpRef.current.zoom, cx, cy);
666
- }, [hostRef, applyViewport, zoomAt]);
764
+ const v = vpRef.current;
765
+ // Target: same world-coord under cursor, zoom = 1.
766
+ const wx = (cx - v.x) / v.zoom;
767
+ const wy = (cy - v.y) / v.zoom;
768
+ const target: ViewportState = { x: cx - wx, y: cy - wy, zoom: 1 };
769
+ const a = animateToRef.current;
770
+ if (a) a(target, PROGRAMMATIC_EASE_MS);
771
+ else applyViewport(target);
772
+ }, [hostRef, applyViewport]);
667
773
 
668
774
  const zoomIn = useCallback(() => {
669
775
  const host = hostRef.current;
@@ -716,6 +822,10 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
716
822
  [applyViewport]
717
823
  );
718
824
 
825
+ // Keep the ref pointed at the latest `animateTo` so the earlier-declared
826
+ // fit / reset callbacks can call it without circular dependency.
827
+ animateToRef.current = animateTo;
828
+
719
829
  const jumpTo = useCallback(
720
830
  (rect: ArtboardRect) => {
721
831
  const host = hostRef.current;
@@ -793,7 +903,13 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
793
903
  // without a physical Ctrl press — so the same branch covers both
794
904
  // Ctrl+wheel (mouse) and pinch-zoom (trackpad).
795
905
  if (e.ctrlKey || e.metaKey) {
796
- const factor = Math.exp(-e.deltaY * WHEEL_ZOOM_K);
906
+ // T32 clamp deltaY into [-50, 50] before the exp() to bring
907
+ // trackpad-pinch (small per-frame delta) and mouse-wheel (one
908
+ // notch = ±100) onto the same perceived-speed curve. Mouse-wheel
909
+ // users still get smooth zoom (clamped notches accumulate at the
910
+ // same exp rate), trackpad-pinch users no longer outpace them.
911
+ const clamped = Math.max(-50, Math.min(50, e.deltaY));
912
+ const factor = Math.exp(-clamped * WHEEL_ZOOM_K);
797
913
  zoomAt(factor, cx, cy);
798
914
  return;
799
915
  }
@@ -1001,11 +1117,24 @@ export function useViewportControllerContext(): ViewportControllerHandle | null
1001
1117
  // drag). A single source-of-truth: only one drag can be active at a time, so
1002
1118
  // the bus holds a single DragState. Each DCArtboard's hook writes here when
1003
1119
  // non-idle and resets to idle on release.
1120
+ /** Optional metadata accompanying a position commit. */
1121
+ export interface CommitPositionsOptions {
1122
+ /**
1123
+ * HUD / undo-stack label override. Default = `"move N artboard(s)"`.
1124
+ * Distribute / align gestures pass their own label so undo HUD reads
1125
+ * `"Undo: equal-space 4 artboards"` instead of `"Undo: move 4 artboards"`.
1126
+ */
1127
+ label?: string;
1128
+ }
1129
+
1004
1130
  interface DragStateBus {
1005
1131
  current: DragState;
1006
1132
  setCurrent: (s: DragState) => void;
1007
1133
  /** Commit drag positions — DesignCanvas wires this to patchCanvasMeta. */
1008
- commitPositions: (moved: { id: string; x: number; y: number }[]) => void;
1134
+ commitPositions: (
1135
+ moved: { id: string; x: number; y: number }[],
1136
+ opts?: CommitPositionsOptions
1137
+ ) => void;
1009
1138
  }
1010
1139
 
1011
1140
  const DragStateContext = createContext<DragStateBus | null>(null);
@@ -1034,10 +1163,19 @@ interface DesignCanvasProps {
1034
1163
  * via `useToolModeOptional` (hand-mode bare-drag pan).
1035
1164
  */
1036
1165
  export function DesignCanvas(props: DesignCanvasProps) {
1166
+ // Phase 20 — per-canvas undo/redo stack (DDR-050 rev 2). The provider
1167
+ // wraps both DesignCanvasInner (so artboard commits push records) AND
1168
+ // the CanvasShell tree (so input-router Cmd+Z / Cmd+Shift+Z + the HUD
1169
+ // share the same context). Stack state is keyed by canvas file path in
1170
+ // `window.top.__maude_undo_stacks` so it survives canvas switches —
1171
+ // close Foo.tsx, open Bar.tsx, come back to Foo.tsx → history intact.
1172
+ const canvasFile = readCanvasMetaFile() ?? undefined;
1037
1173
  return (
1038
- <ToolProvider>
1039
- <DesignCanvasInner {...props} />
1040
- </ToolProvider>
1174
+ <UndoStackProvider canvasFile={canvasFile}>
1175
+ <ToolProvider>
1176
+ <DesignCanvasInner {...props} />
1177
+ </ToolProvider>
1178
+ </UndoStackProvider>
1041
1179
  );
1042
1180
  }
1043
1181
  DesignCanvas.displayName = 'DesignCanvas';
@@ -1188,20 +1326,56 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1188
1326
  // hook is non-idle; SnapGuideOverlay (in canvas-shell) reads guides.
1189
1327
  const [dragCurrent, setDragCurrent] = useState<DragState>({ kind: 'idle' });
1190
1328
 
1191
- const commitArtboardPositions = useCallback((moved: { id: string; x: number; y: number }[]) => {
1192
- const movedById = new Map(moved.map((m) => [m.id, m]));
1193
- const next = artboardsRef.current.map((r) => {
1194
- const m = movedById.get(r.id);
1195
- if (m) return { ...r, x: m.x, y: m.y };
1196
- return r;
1197
- });
1198
- // Optimistic local update — DOM reflects the new position the moment
1199
- // the drag drops, no iframe reload required. The PATCH below catches
1200
- // the server up; if it fails we already logged it via `patchCanvasMeta`.
1201
- setArtboards(next);
1202
- patchCanvasMeta({ layout: { artboards: next } });
1329
+ const undoStack = useUndoStackOptional();
1330
+ const undoSinks = useUndoSinks();
1331
+ // Stable ref so the commit callback (memoized once, on mount) always reads
1332
+ // the latest stack value without re-creating the callback on every render.
1333
+ const undoStackRef = useRef(undoStack);
1334
+ undoStackRef.current = undoStack;
1335
+
1336
+ /**
1337
+ * Applies a full artboard layout: optimistic local React state update +
1338
+ * server PATCH. Used as the `layoutPatchFn` sink registered with the
1339
+ * undo stack — both the initial commit (do) and every undo/redo replay
1340
+ * route through here, so React state always tracks the server.
1341
+ */
1342
+ const applyArtboardLayout = useCallback((layout: unknown) => {
1343
+ setArtboards(layout as ArtboardRect[]);
1344
+ patchCanvasMeta({ layout: { artboards: layout as ArtboardRect[] } });
1203
1345
  }, []);
1204
1346
 
1347
+ // Register the layout patch sink with the undo provider so the rebuilt
1348
+ // MoveArtboardsCommand (after a canvas switch + return) can apply layouts
1349
+ // through THIS iframe's React state, not the gone iframe's stale closures.
1350
+ useEffect(() => {
1351
+ undoSinks.setSink('layoutPatchFn', applyArtboardLayout);
1352
+ return () => undoSinks.setSink('layoutPatchFn', undefined);
1353
+ }, [undoSinks, applyArtboardLayout]);
1354
+
1355
+ const commitArtboardPositions = useCallback(
1356
+ (moved: { id: string; x: number; y: number }[], opts?: CommitPositionsOptions) => {
1357
+ const movedById = new Map(moved.map((m) => [m.id, m]));
1358
+ const before = artboardsRef.current;
1359
+ const next = before.map((r) => {
1360
+ const m = movedById.get(r.id);
1361
+ if (m) return { ...r, x: m.x, y: m.y };
1362
+ return r;
1363
+ });
1364
+ // Phase 20 — skip pushing no-op drags (click-without-movement). The
1365
+ // user got back what they had, no edit happened, undo would do nothing.
1366
+ if (!diffLayoutPositions(before, next)) return;
1367
+ const record = buildMoveArtboardsRecord({
1368
+ before: before.map((r) => ({ id: r.id, x: r.x, y: r.y, w: r.w, h: r.h })),
1369
+ after: next.map((r) => ({ id: r.id, x: r.x, y: r.y, w: r.w, h: r.h })),
1370
+ ...(opts?.label ? { label: opts.label } : {}),
1371
+ });
1372
+ // push() invokes the rebuilt cmd.do() = applyArtboardLayout(next) BEFORE
1373
+ // updating the stack, so the optimistic local + PATCH flow is preserved.
1374
+ void undoStackRef.current.push(record);
1375
+ },
1376
+ []
1377
+ );
1378
+
1205
1379
  const dragBus = useMemo<DragStateBus>(
1206
1380
  () => ({
1207
1381
  current: dragCurrent,
@@ -1297,7 +1471,7 @@ export function DCArtboard({
1297
1471
  children: ReactNode;
1298
1472
  }) {
1299
1473
  const ctx = useWorldContext();
1300
- const controller = useViewportControllerContext();
1474
+ const _controller = useViewportControllerContext();
1301
1475
  const toolMode = useToolModeOptional();
1302
1476
  const selSet = useSelectionSetOptional();
1303
1477
  const dragBus = useDragStateContext();
@@ -1343,9 +1517,11 @@ export function DCArtboard({
1343
1517
  );
1344
1518
  }
1345
1519
  const isActive = ctx.activeArtboardId === id;
1346
- const onFocus = () => {
1347
- if (controller) controller.jumpTo(rect);
1348
- };
1520
+ // G2v2 earlier the label single-click called `controller.jumpTo(rect)`,
1521
+ // auto-zooming on every click. Per post-Wave-3.5 feedback this was
1522
+ // surprising. The label button stays for a11y (focus + screen-reader
1523
+ // label) but no longer mutates the viewport. Cmd+1 + the zoom HUD still
1524
+ // expose the manual zoom path.
1349
1525
 
1350
1526
  // Am I involved in the current drag (as leader or follower)?
1351
1527
  const busDrag = dragBus?.current;
@@ -1355,50 +1531,36 @@ export function DCArtboard({
1355
1531
  const isFollower = !!followerOffset;
1356
1532
  const isInDrag = isLeader || isFollower;
1357
1533
 
1358
- // Ghost position (world coords).
1359
- let ghostX = 0;
1360
- let ghostY = 0;
1534
+ // Live drag position (world coords). The article's own `left/top` updates
1535
+ // each frame while the drag is in flight — no ghost placeholder, no faded
1536
+ // original. commitFromState then persists the final position on settle.
1537
+ let liveX = rect.x;
1538
+ let liveY = rect.y;
1361
1539
  if (busDrag?.kind === 'dragging') {
1362
1540
  if (isLeader) {
1363
- ghostX = busDrag.leaderRect.x;
1364
- ghostY = busDrag.leaderRect.y;
1541
+ liveX = busDrag.leaderRect.x;
1542
+ liveY = busDrag.leaderRect.y;
1365
1543
  } else if (isFollower && followerOffset) {
1366
- ghostX = busDrag.leaderRect.x + followerOffset.offsetX;
1367
- ghostY = busDrag.leaderRect.y + followerOffset.offsetY;
1544
+ liveX = busDrag.leaderRect.x + followerOffset.offsetX;
1545
+ liveY = busDrag.leaderRect.y + followerOffset.offsetY;
1368
1546
  }
1369
1547
  }
1370
1548
 
1371
1549
  const handleProps = dragHook.bindHandle();
1372
1550
 
1373
1551
  return (
1374
- <>
1375
- <article
1376
- className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
1377
- data-dc-screen={id}
1378
- aria-current={isActive ? 'true' : undefined}
1379
- style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
1380
- {...handleProps}
1381
- >
1382
- <button
1383
- type="button"
1384
- className="dc-artboard-label sku"
1385
- onClick={onFocus}
1386
- aria-label={`Focus artboard ${label}`}
1387
- >
1388
- {label}
1389
- </button>
1390
- <div className="dc-artboard-body">{children}</div>
1391
- </article>
1392
- {isInDrag ? (
1393
- <div
1394
- className="dc-artboard-ghost"
1395
- aria-hidden="true"
1396
- style={{ left: ghostX, top: ghostY, width: rect.w, height: rect.h }}
1397
- >
1398
- <div className="dc-artboard-ghost-label">{label}</div>
1399
- </div>
1400
- ) : null}
1401
- </>
1552
+ <article
1553
+ className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
1554
+ data-dc-screen={id}
1555
+ aria-current={isActive ? 'true' : undefined}
1556
+ style={{ left: liveX, top: liveY, width: rect.w, height: rect.h }}
1557
+ {...handleProps}
1558
+ >
1559
+ <button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
1560
+ {label}
1561
+ </button>
1562
+ <div className="dc-artboard-body">{children}</div>
1563
+ </article>
1402
1564
  );
1403
1565
  }
1404
1566
  DCArtboard.displayName = 'DCArtboard';
@@ -1410,6 +1572,15 @@ DCArtboard.displayName = 'DCArtboard';
1410
1572
  // come from `dragBus.current.snap.guides`; world→screen projection uses the
1411
1573
  // live viewport (`v.x + worldCoord * v.zoom` — same convention as `writeTransform`).
1412
1574
 
1575
+ // DDR-046 — render kind-aware snap guides + distance pills. `kind === 'sibling'`
1576
+ // gets the confident magenta + glow; `kind === 'grid'` gets a lighter gray (the
1577
+ // grid is fallback when no sibling fires). Pre-DDR-046 guides emit no `kind`
1578
+ // field — treat as sibling for back-compat. The `Δ{Math.round(delta)}` pill
1579
+ // renders mid-span when |delta| > 0 and screen-span exceeds 60 px (smaller
1580
+ // spans hide the pill so it never overlaps the line itself).
1581
+ const MIN_PILL_SPAN_PX = 60;
1582
+ const GUIDE_THICKNESS_PX = 2;
1583
+
1413
1584
  export function SnapGuideOverlay() {
1414
1585
  const dragBus = useDragStateContext();
1415
1586
  const world = useWorldContext();
@@ -1421,49 +1592,78 @@ export function SnapGuideOverlay() {
1421
1592
  return (
1422
1593
  <>
1423
1594
  {s.snap.guides.map((g, i) => {
1595
+ const kindClass = g.kind === 'grid' ? 'dc-snap-guide--grid' : 'dc-snap-guide--sibling';
1596
+ const delta = g.delta ?? 0;
1597
+ const showPill = g.kind !== 'grid' && Math.abs(delta) > 0;
1424
1598
  if (g.axis === 'x') {
1425
1599
  const sx = vp.x + g.pos * vp.zoom;
1426
1600
  const sFrom = vp.y + g.from * vp.zoom;
1427
1601
  const sTo = vp.y + g.to * vp.zoom;
1602
+ const screenSpan = sTo - sFrom;
1428
1603
  return (
1429
- <div
1604
+ <Fragment
1430
1605
  // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
1431
1606
  key={`x-${i}`}
1432
- className="dc-snap-guide"
1433
- style={{
1434
- position: 'fixed',
1435
- pointerEvents: 'none',
1436
- background: 'var(--accent, #d63b1f)',
1437
- left: sx,
1438
- top: sFrom,
1439
- width: 1,
1440
- height: Math.max(1, sTo - sFrom),
1441
- zIndex: 6,
1442
- }}
1443
- aria-hidden="true"
1444
- />
1607
+ >
1608
+ <div
1609
+ className={`dc-snap-guide ${kindClass}`}
1610
+ style={{
1611
+ left: sx - GUIDE_THICKNESS_PX / 2,
1612
+ top: sFrom,
1613
+ width: GUIDE_THICKNESS_PX,
1614
+ height: Math.max(GUIDE_THICKNESS_PX, screenSpan),
1615
+ }}
1616
+ aria-hidden="true"
1617
+ />
1618
+ {showPill && screenSpan >= MIN_PILL_SPAN_PX && (
1619
+ <div
1620
+ className="dc-snap-pill"
1621
+ style={{
1622
+ left: sx,
1623
+ top: sFrom + screenSpan / 2,
1624
+ transform: 'translate(-50%, -50%)',
1625
+ }}
1626
+ aria-hidden="true"
1627
+ >
1628
+ Δ{Math.round(Math.abs(delta))}
1629
+ </div>
1630
+ )}
1631
+ </Fragment>
1445
1632
  );
1446
1633
  }
1447
1634
  const sy = vp.y + g.pos * vp.zoom;
1448
1635
  const sFrom = vp.x + g.from * vp.zoom;
1449
1636
  const sTo = vp.x + g.to * vp.zoom;
1637
+ const screenSpan = sTo - sFrom;
1450
1638
  return (
1451
- <div
1639
+ <Fragment
1452
1640
  // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
1453
1641
  key={`y-${i}`}
1454
- className="dc-snap-guide"
1455
- style={{
1456
- position: 'fixed',
1457
- pointerEvents: 'none',
1458
- background: 'var(--accent, #d63b1f)',
1459
- left: sFrom,
1460
- top: sy,
1461
- width: Math.max(1, sTo - sFrom),
1462
- height: 1,
1463
- zIndex: 6,
1464
- }}
1465
- aria-hidden="true"
1466
- />
1642
+ >
1643
+ <div
1644
+ className={`dc-snap-guide ${kindClass}`}
1645
+ style={{
1646
+ left: sFrom,
1647
+ top: sy - GUIDE_THICKNESS_PX / 2,
1648
+ width: Math.max(GUIDE_THICKNESS_PX, screenSpan),
1649
+ height: GUIDE_THICKNESS_PX,
1650
+ }}
1651
+ aria-hidden="true"
1652
+ />
1653
+ {showPill && screenSpan >= MIN_PILL_SPAN_PX && (
1654
+ <div
1655
+ className="dc-snap-pill"
1656
+ style={{
1657
+ left: sFrom + screenSpan / 2,
1658
+ top: sy,
1659
+ transform: 'translate(-50%, -50%)',
1660
+ }}
1661
+ aria-hidden="true"
1662
+ >
1663
+ Δ{Math.round(Math.abs(delta))}
1664
+ </div>
1665
+ )}
1666
+ </Fragment>
1467
1667
  );
1468
1668
  })}
1469
1669
  </>
@@ -1484,6 +1684,14 @@ export function DCPostIt({ children }: { children: ReactNode }) {
1484
1684
  // same vocabulary; if a DS wants to restyle, it can target `.dc-mm` /
1485
1685
  // `.dc-zoom-tb` directly.
1486
1686
 
1687
+ // DDR-046 — Floating chrome (mini-map, zoom HUD, tool palette, popovers, comment
1688
+ // composer, export dialog) drops the brutalist 4 × 4 × 0 hard offset shadow in
1689
+ // favor of a soft ambient. The hard offset stays on app-shell chrome only
1690
+ // (menubar, header, tab strip) — that's the project's intentional brutalist
1691
+ // identity. Floating layer = soft. App frame = hard.
1692
+ const FLOATING_SHADOW = '0 6px 24px color-mix(in oklab, var(--fg-0, #1c1917) 10%, transparent)';
1693
+ const FLOATING_RADIUS = '8px';
1694
+
1487
1695
  const OVERLAY_CSS = `
1488
1696
  .dc-mm {
1489
1697
  position: absolute;
@@ -1491,15 +1699,15 @@ const OVERLAY_CSS = `
1491
1699
  bottom: 16px;
1492
1700
  width: 196px;
1493
1701
  height: 132px;
1494
- background: var(--bg-2, var(--bg-1, rgba(255,255,255,0.98)));
1702
+ background: var(--bg-0, #ffffff);
1495
1703
  border: 1px solid var(--fg-0, #1c1917);
1496
- border-radius: 0;
1704
+ border-radius: ${FLOATING_RADIUS};
1497
1705
  font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1498
1706
  font-size: 10px;
1499
1707
  color: var(--fg-1, rgba(40,30,20,0.7));
1500
1708
  z-index: 6;
1501
1709
  user-select: none;
1502
- box-shadow: 4px 4px 0 var(--fg-0, #1c1917);
1710
+ box-shadow: ${FLOATING_SHADOW};
1503
1711
  overflow: hidden;
1504
1712
  }
1505
1713
  .dc-mm-hd {
@@ -1508,6 +1716,7 @@ const OVERLAY_CSS = `
1508
1716
  letter-spacing: 0.05em;
1509
1717
  text-transform: uppercase;
1510
1718
  font-size: 9px;
1719
+ background: var(--bg-1, #f4f1ea);
1511
1720
  }
1512
1721
  .dc-mm-body {
1513
1722
  position: relative;
@@ -1515,15 +1724,21 @@ const OVERLAY_CSS = `
1515
1724
  height: calc(100% - 22px);
1516
1725
  overflow: hidden;
1517
1726
  cursor: pointer;
1727
+ background: var(--bg-1, #f4f1ea);
1518
1728
  }
1519
1729
  .dc-mm-rect {
1520
1730
  position: absolute;
1521
- background: rgba(0,0,0,0.06);
1522
- border: 1px solid rgba(0,0,0,0.18);
1731
+ background: color-mix(in oklab, var(--fg-0, #1c1917) 14%, transparent);
1732
+ border: 1px solid color-mix(in oklab, var(--fg-0, #1c1917) 28%, transparent);
1733
+ border-radius: 1px;
1523
1734
  }
1735
+ /* Filled viewport indicator — FigJam / Figma both ship a tinted fill, not
1736
+ outline-only. Reads from a glance as "what slice of the world you're on". */
1524
1737
  .dc-mm-vp {
1525
1738
  position: absolute;
1526
- border: 2px solid #d63b1f;
1739
+ background: color-mix(in oklab, var(--accent, #d63b1f) 12%, transparent);
1740
+ border: 1.5px solid var(--accent, #d63b1f);
1741
+ border-radius: 1px;
1527
1742
  pointer-events: none;
1528
1743
  }
1529
1744
  .dc-zoom-tb {
@@ -1533,15 +1748,15 @@ const OVERLAY_CSS = `
1533
1748
  transform: translateX(-50%);
1534
1749
  display: flex;
1535
1750
  align-items: stretch;
1536
- background: var(--bg-2, rgba(255,255,255,0.94));
1751
+ background: var(--bg-0, #ffffff);
1537
1752
  border: 1px solid var(--fg-0, #1c1917);
1538
- border-radius: 0;
1753
+ border-radius: ${FLOATING_RADIUS};
1539
1754
  overflow: hidden;
1540
1755
  font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1541
1756
  font-size: 11px;
1542
1757
  color: var(--fg-1, rgba(40,30,20,0.85));
1543
1758
  z-index: 6;
1544
- box-shadow: 4px 4px 0 var(--fg-0, #1c1917);
1759
+ box-shadow: ${FLOATING_SHADOW};
1545
1760
  }
1546
1761
  .dc-zoom-tb button {
1547
1762
  appearance: none;
@@ -1554,10 +1769,11 @@ const OVERLAY_CSS = `
1554
1769
  cursor: pointer;
1555
1770
  min-width: 36px;
1556
1771
  text-align: center;
1772
+ transition: background 80ms linear;
1557
1773
  }
1558
1774
  .dc-zoom-tb button:last-child { border-right: 0; }
1559
- .dc-zoom-tb button:hover { background: rgba(0,0,0,0.04); }
1560
- .dc-zoom-tb button:focus-visible { outline: 2px solid #d63b1f; outline-offset: -2px; }
1775
+ .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; }
1561
1777
  .dc-zoom-tb-pct { font-variant-numeric: tabular-nums; min-width: 52px; }
1562
1778
  `.trim();
1563
1779
 
@@ -1992,3 +2208,359 @@ export function useArtboardBounds(ref: RefObject<HTMLElement | null>): {
1992
2208
  // Re-export `useRef` so `useArtboardBounds` consumers can keep a single
1993
2209
  // import line from `@maude/canvas-lib`.
1994
2210
  export { useRef };
2211
+
2212
+ // ─────────────────────────────────────────────────────────────────────────────
2213
+ // Motion subsystem (Phase 3.7 / DDR-049 — Motion One is the canonical runtime)
2214
+ //
2215
+ // These helpers are tree-shakeable. A canvas that does not import any of them
2216
+ // pays no bundle cost (motion/react is externalised via RUNTIME_PACKAGES, so
2217
+ // even when imported the byte cost lives in a single shared runtime bundle,
2218
+ // not per-canvas).
2219
+ //
2220
+ // Roles map 1:1 to the 8 motion-vocabulary names enforced by motion-critic +
2221
+ // design-system-keeper. Each role binds to a DS duration + easing token from
2222
+ // colors_and_type.css; useMotionTokens() reads the live CSS custom property
2223
+ // values so the binding survives token edits without rebuilding canvas-lib.
2224
+ //
2225
+ // Bounded-geometry guarantee — every <MotionDemo> root sets `overflow: hidden`
2226
+ // in inline style. That defends against sparkle-on-tile overflow regardless of
2227
+ // the host class chrome. See SUB-AGENT-PROMPTS.md → ANIMATION SAFETY.
2228
+ // ─────────────────────────────────────────────────────────────────────────────
2229
+
2230
+ export type MotionRole =
2231
+ | 'flip'
2232
+ | 'panel'
2233
+ | 'route'
2234
+ | 'soft'
2235
+ | 'spring'
2236
+ | 'scroll'
2237
+ | 'drag'
2238
+ | 'presence';
2239
+
2240
+ export type MotionLoop = 'always' | 'hover' | 'once';
2241
+
2242
+ interface RoleConfig {
2243
+ durationToken: string;
2244
+ easingToken: string;
2245
+ keyframes: Record<string, number[]>;
2246
+ fallbackMs: number;
2247
+ }
2248
+
2249
+ export const MOTION_ROLE_DEFAULTS: Record<MotionRole, RoleConfig> = {
2250
+ flip: {
2251
+ durationToken: '--dur-flip',
2252
+ easingToken: '--ease-out',
2253
+ keyframes: { y: [0, -12, 0] },
2254
+ fallbackMs: 220,
2255
+ },
2256
+ panel: {
2257
+ durationToken: '--dur-panel',
2258
+ easingToken: '--ease-in-out',
2259
+ keyframes: { x: [-80, 0, -80] },
2260
+ fallbackMs: 320,
2261
+ },
2262
+ route: {
2263
+ durationToken: '--dur-route',
2264
+ easingToken: '--ease-out',
2265
+ keyframes: { opacity: [0, 1, 0], scale: [0.92, 1, 0.92] },
2266
+ fallbackMs: 480,
2267
+ },
2268
+ soft: {
2269
+ durationToken: '--dur-soft',
2270
+ easingToken: '--ease-out',
2271
+ keyframes: { opacity: [0, 1, 0] },
2272
+ fallbackMs: 160,
2273
+ },
2274
+ spring: {
2275
+ durationToken: '--dur-panel',
2276
+ easingToken: 'spring',
2277
+ keyframes: { y: [0, -16, 0] },
2278
+ fallbackMs: 320,
2279
+ },
2280
+ scroll: {
2281
+ durationToken: '--dur-route',
2282
+ easingToken: '--ease-in-out',
2283
+ keyframes: { x: [0, 24, 0] },
2284
+ fallbackMs: 480,
2285
+ },
2286
+ drag: {
2287
+ durationToken: '--dur-flip',
2288
+ easingToken: '--ease-out',
2289
+ keyframes: { rotate: [0, 4, 0] },
2290
+ fallbackMs: 220,
2291
+ },
2292
+ presence: {
2293
+ durationToken: '--dur-soft',
2294
+ easingToken: '--ease-out',
2295
+ keyframes: { opacity: [0, 1], scale: [0.9, 1] },
2296
+ fallbackMs: 160,
2297
+ },
2298
+ };
2299
+
2300
+ /**
2301
+ * Reads --dur-* + --ease-* CSS custom properties from documentElement and
2302
+ * returns a plain map suitable for plugging into motion/react's transition
2303
+ * config. ms values parsed to numbers; easing tokens returned as strings (the
2304
+ * raw token value, e.g. "cubic-bezier(0, 0, 0.2, 1)" — motion/react accepts
2305
+ * the string form).
2306
+ */
2307
+ export function useMotionTokens(): {
2308
+ durations: Record<string, number>;
2309
+ easings: Record<string, string>;
2310
+ } {
2311
+ const [snap, setSnap] = useState(() => readMotionTokensOnce());
2312
+ useEffect(() => {
2313
+ setSnap(readMotionTokensOnce());
2314
+ if (typeof MutationObserver === 'undefined') return;
2315
+ const obs = new MutationObserver(() => setSnap(readMotionTokensOnce()));
2316
+ obs.observe(document.documentElement, {
2317
+ attributes: true,
2318
+ attributeFilter: ['data-theme', 'data-reduced-motion'],
2319
+ });
2320
+ return () => obs.disconnect();
2321
+ }, []);
2322
+ return snap;
2323
+ }
2324
+
2325
+ function readMotionTokensOnce(): {
2326
+ durations: Record<string, number>;
2327
+ easings: Record<string, string>;
2328
+ } {
2329
+ if (typeof window === 'undefined' || typeof getComputedStyle === 'undefined') {
2330
+ return { durations: {}, easings: {} };
2331
+ }
2332
+ const cs = getComputedStyle(document.documentElement);
2333
+ const durations: Record<string, number> = {};
2334
+ const easings: Record<string, string> = {};
2335
+ const durKeys = ['--dur-flip', '--dur-panel', '--dur-route', '--dur-soft'];
2336
+ const easeKeys = ['--ease-out', '--ease-in', '--ease-in-out'];
2337
+ for (const k of durKeys) {
2338
+ const raw = cs.getPropertyValue(k).trim();
2339
+ if (!raw) continue;
2340
+ const n = Number.parseFloat(raw);
2341
+ if (Number.isFinite(n)) {
2342
+ durations[k] = raw.endsWith('s') && !raw.endsWith('ms') ? n * 1000 : n;
2343
+ }
2344
+ }
2345
+ for (const k of easeKeys) {
2346
+ const raw = cs.getPropertyValue(k).trim();
2347
+ if (raw) easings[k] = raw;
2348
+ }
2349
+ return { durations, easings };
2350
+ }
2351
+
2352
+ /**
2353
+ * Maps a DS easing token name to the value motion/react's `transition.ease`
2354
+ * accepts. Returns the live CSS string when readable, otherwise a sane default
2355
+ * matching Material's "standard" curve.
2356
+ */
2357
+ export function easingFromToken(
2358
+ token: string,
2359
+ easings: Record<string, string>
2360
+ ): string | undefined {
2361
+ const live = easings[token];
2362
+ if (live) return live;
2363
+ if (token === '--ease-out') return 'cubic-bezier(0, 0, 0.2, 1)';
2364
+ if (token === '--ease-in') return 'cubic-bezier(0.4, 0, 1, 1)';
2365
+ if (token === '--ease-in-out') return 'cubic-bezier(0.4, 0, 0.2, 1)';
2366
+ return undefined;
2367
+ }
2368
+
2369
+ interface MotionDemoProps {
2370
+ role: MotionRole;
2371
+ loop?: MotionLoop;
2372
+ children?: ReactNode;
2373
+ small?: boolean;
2374
+ className?: string;
2375
+ label?: string;
2376
+ }
2377
+
2378
+ /**
2379
+ * The foundational motion building block. Wraps motion/react's animated <div>
2380
+ * with token-bound duration + easing + reduced-motion short-circuit.
2381
+ *
2382
+ * Default loop="always" so initial paint shows motion — the "looks dead at
2383
+ * rest" failure mode is the regression Phase 3.7 exists to prevent.
2384
+ */
2385
+ export function MotionDemo({
2386
+ role,
2387
+ loop = 'always',
2388
+ children,
2389
+ small = false,
2390
+ className,
2391
+ label,
2392
+ }: MotionDemoProps) {
2393
+ const cfg = MOTION_ROLE_DEFAULTS[role];
2394
+ const tokens = useMotionTokens();
2395
+ const reduced = _useReducedMotion();
2396
+ const durationMs = tokens.durations[cfg.durationToken] ?? cfg.fallbackMs;
2397
+ const isSpring = cfg.easingToken === 'spring';
2398
+ const ease = isSpring ? undefined : easingFromToken(cfg.easingToken, tokens.easings);
2399
+ const repeat = reduced || loop === 'once' ? 0 : Number.POSITIVE_INFINITY;
2400
+ const repeatType: 'reverse' | 'loop' = loop === 'always' ? 'reverse' : 'loop';
2401
+ const animate = reduced ? undefined : cfg.keyframes;
2402
+
2403
+ return (
2404
+ <div
2405
+ className={`motion-demo${className ? ` ${className}` : ''}`}
2406
+ data-role={role}
2407
+ data-small={small ? 'true' : undefined}
2408
+ style={{ overflow: 'hidden', position: 'relative' }}
2409
+ >
2410
+ <_motionImpl.div
2411
+ animate={animate}
2412
+ transition={{
2413
+ duration: durationMs / 1000,
2414
+ ease,
2415
+ type: isSpring ? 'spring' : 'tween',
2416
+ repeat,
2417
+ repeatType,
2418
+ }}
2419
+ className="motion-demo__target"
2420
+ aria-label={label}
2421
+ style={small ? { width: 32, height: 32 } : undefined}
2422
+ >
2423
+ {children ?? <div className="motion-demo__chip" />}
2424
+ </_motionImpl.div>
2425
+ </div>
2426
+ );
2427
+ }
2428
+
2429
+ interface MotionTrackProps {
2430
+ children: ReactNode;
2431
+ staggerMs?: number;
2432
+ className?: string;
2433
+ }
2434
+
2435
+ /**
2436
+ * Row container with CSS animation-delay stagger between children.
2437
+ */
2438
+ export function MotionTrack({ children, staggerMs = 40, className }: MotionTrackProps) {
2439
+ const items = Array.isArray(children) ? children : [children];
2440
+ return (
2441
+ <div
2442
+ className={`motion-track${className ? ` ${className}` : ''}`}
2443
+ style={{ display: 'flex', gap: 12, alignItems: 'center' }}
2444
+ >
2445
+ {items.map((c, i) => (
2446
+ // biome-ignore lint/suspicious/noArrayIndexKey: stagger row is index-positional by design; no reorder/insertion semantics
2447
+ <div key={i} style={{ animationDelay: `${i * staggerMs}ms` }}>
2448
+ {c}
2449
+ </div>
2450
+ ))}
2451
+ </div>
2452
+ );
2453
+ }
2454
+
2455
+ interface TokenPlaybackProps {
2456
+ duration: string;
2457
+ easing?: string;
2458
+ label?: string;
2459
+ keyframes?: Record<string, number[]>;
2460
+ }
2461
+
2462
+ /**
2463
+ * Click-to-fire single-shot replay chip. Used in the motion specimen so
2464
+ * reviewers can probe a single token without hovering a card.
2465
+ */
2466
+ export function TokenPlayback({
2467
+ duration,
2468
+ easing = '--ease-out',
2469
+ label,
2470
+ keyframes = { y: [0, -8, 0] },
2471
+ }: TokenPlaybackProps) {
2472
+ const tokens = useMotionTokens();
2473
+ const reduced = _useReducedMotion();
2474
+ const durationMs = tokens.durations[duration] ?? 220;
2475
+ const ease = easing === 'spring' ? undefined : easingFromToken(easing, tokens.easings);
2476
+ const [tick, setTick] = useState(0);
2477
+ const fire = useCallback(() => {
2478
+ if (!reduced) setTick((n) => n + 1);
2479
+ }, [reduced]);
2480
+ return (
2481
+ <button
2482
+ type="button"
2483
+ className="token-playback"
2484
+ onClick={fire}
2485
+ style={{
2486
+ display: 'inline-flex',
2487
+ alignItems: 'center',
2488
+ gap: 8,
2489
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
2490
+ fontSize: 12,
2491
+ padding: '6px 10px',
2492
+ border: '1px solid var(--border-1, currentColor)',
2493
+ borderRadius: 4,
2494
+ background: 'transparent',
2495
+ color: 'inherit',
2496
+ cursor: 'pointer',
2497
+ }}
2498
+ >
2499
+ <span style={{ opacity: 0.6 }}>{label ?? duration}</span>
2500
+ <_motionImpl.span
2501
+ key={tick}
2502
+ animate={tick === 0 ? undefined : keyframes}
2503
+ transition={{
2504
+ duration: durationMs / 1000,
2505
+ ease,
2506
+ type: easing === 'spring' ? 'spring' : 'tween',
2507
+ }}
2508
+ style={{
2509
+ display: 'inline-block',
2510
+ width: 8,
2511
+ height: 8,
2512
+ background: 'var(--accent, currentColor)',
2513
+ borderRadius: '50%',
2514
+ }}
2515
+ />
2516
+ <span style={{ fontVariantNumeric: 'tabular-nums' }}>{durationMs}ms</span>
2517
+ </button>
2518
+ );
2519
+ }
2520
+
2521
+ /**
2522
+ * Chrome toggle for the motion specimen — flips data-reduced-motion="true"
2523
+ * on <html> so reviewers can eyeball both branches without OS settings.
2524
+ * Inspection aid, never a replacement for prefers-reduced-motion.
2525
+ */
2526
+ export function ReducedMotionToggle() {
2527
+ const [on, setOn] = useState(false);
2528
+ useEffect(() => {
2529
+ const el = document.documentElement;
2530
+ const initial = el.getAttribute('data-reduced-motion') === 'true';
2531
+ setOn(initial);
2532
+ }, []);
2533
+ const toggle = useCallback(() => {
2534
+ const el = document.documentElement;
2535
+ const next = !on;
2536
+ if (next) el.setAttribute('data-reduced-motion', 'true');
2537
+ else el.removeAttribute('data-reduced-motion');
2538
+ setOn(next);
2539
+ }, [on]);
2540
+ return (
2541
+ <button
2542
+ type="button"
2543
+ className="reduced-motion-toggle"
2544
+ onClick={toggle}
2545
+ aria-pressed={on}
2546
+ style={{
2547
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
2548
+ fontSize: 11,
2549
+ padding: '4px 8px',
2550
+ border: '1px solid var(--border-1, currentColor)',
2551
+ borderRadius: 3,
2552
+ background: on ? 'var(--accent, currentColor)' : 'transparent',
2553
+ color: on ? 'var(--bg-0, white)' : 'inherit',
2554
+ cursor: 'pointer',
2555
+ }}
2556
+ >
2557
+ reduced-motion: {on ? 'on' : 'off'}
2558
+ </button>
2559
+ );
2560
+ }
2561
+
2562
+ export {
2563
+ _motionImpl as motion,
2564
+ _useReducedMotion as useReducedMotion,
2565
+ _MotionAnimatePresence as AnimatePresence,
2566
+ };