@1agh/maude 0.19.1 → 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 (49) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +112 -38
  3. package/plugins/design/dev-server/annotations-layer.tsx +204 -95
  4. package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
  5. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  6. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  7. package/plugins/design/dev-server/canvas-lib.tsx +506 -30
  8. package/plugins/design/dev-server/canvas-shell.tsx +352 -20
  9. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  10. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  11. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  12. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  13. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  14. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +3 -3
  16. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  17. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  18. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  19. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  20. package/plugins/design/dev-server/handoff.ts +24 -0
  21. package/plugins/design/dev-server/http.ts +27 -0
  22. package/plugins/design/dev-server/input-router.tsx +52 -2
  23. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  24. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  25. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  26. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  27. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  28. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  29. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  30. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  31. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  32. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  33. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  34. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  35. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  36. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  37. package/plugins/design/dev-server/undo-stack.ts +240 -0
  38. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  39. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  40. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  41. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  42. package/plugins/design/templates/_shell.html +17 -6
  43. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  44. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  45. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  46. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  47. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  48. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  49. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -81,10 +81,21 @@ 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';
86
96
  import { useSelectionSetOptional } from './use-selection-set.tsx';
87
97
  import { ToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
98
+ import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
88
99
 
89
100
  // ─────────────────────────────────────────────────────────────────────────────
90
101
  // Module constants
@@ -448,6 +459,26 @@ interface PersistedArtboardLayout {
448
459
  y: number;
449
460
  }
450
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
+
451
482
  function patchCanvasMeta(patch: {
452
483
  viewport?: ViewportState;
453
484
  layout?: { artboards: ArtboardRect[] };
@@ -469,6 +500,10 @@ function patchCanvasMeta(patch: {
469
500
  })),
470
501
  };
471
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();
472
507
  fetch('/_api/canvas-meta', {
473
508
  method: 'PATCH',
474
509
  headers: { 'content-type': 'application/json' },
@@ -700,9 +735,22 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
700
735
  [applyViewport]
701
736
  );
702
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;
703
749
  const fit = useCallback(() => {
704
750
  const next = computeFitRef.current();
705
- applyViewport(next);
751
+ const a = animateToRef.current;
752
+ if (a) a(next, PROGRAMMATIC_EASE_MS);
753
+ else applyViewport(next);
706
754
  }, [applyViewport]);
707
755
 
708
756
  const reset = useCallback(() => {
@@ -713,8 +761,15 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
713
761
  }
714
762
  const cx = host.clientWidth / 2;
715
763
  const cy = host.clientHeight / 2;
716
- zoomAt(1 / vpRef.current.zoom, cx, cy);
717
- }, [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]);
718
773
 
719
774
  const zoomIn = useCallback(() => {
720
775
  const host = hostRef.current;
@@ -767,6 +822,10 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
767
822
  [applyViewport]
768
823
  );
769
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
+
770
829
  const jumpTo = useCallback(
771
830
  (rect: ArtboardRect) => {
772
831
  const host = hostRef.current;
@@ -844,7 +903,13 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
844
903
  // without a physical Ctrl press — so the same branch covers both
845
904
  // Ctrl+wheel (mouse) and pinch-zoom (trackpad).
846
905
  if (e.ctrlKey || e.metaKey) {
847
- 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);
848
913
  zoomAt(factor, cx, cy);
849
914
  return;
850
915
  }
@@ -1052,11 +1117,24 @@ export function useViewportControllerContext(): ViewportControllerHandle | null
1052
1117
  // drag). A single source-of-truth: only one drag can be active at a time, so
1053
1118
  // the bus holds a single DragState. Each DCArtboard's hook writes here when
1054
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
+
1055
1130
  interface DragStateBus {
1056
1131
  current: DragState;
1057
1132
  setCurrent: (s: DragState) => void;
1058
1133
  /** Commit drag positions — DesignCanvas wires this to patchCanvasMeta. */
1059
- commitPositions: (moved: { id: string; x: number; y: number }[]) => void;
1134
+ commitPositions: (
1135
+ moved: { id: string; x: number; y: number }[],
1136
+ opts?: CommitPositionsOptions
1137
+ ) => void;
1060
1138
  }
1061
1139
 
1062
1140
  const DragStateContext = createContext<DragStateBus | null>(null);
@@ -1085,10 +1163,19 @@ interface DesignCanvasProps {
1085
1163
  * via `useToolModeOptional` (hand-mode bare-drag pan).
1086
1164
  */
1087
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;
1088
1173
  return (
1089
- <ToolProvider>
1090
- <DesignCanvasInner {...props} />
1091
- </ToolProvider>
1174
+ <UndoStackProvider canvasFile={canvasFile}>
1175
+ <ToolProvider>
1176
+ <DesignCanvasInner {...props} />
1177
+ </ToolProvider>
1178
+ </UndoStackProvider>
1092
1179
  );
1093
1180
  }
1094
1181
  DesignCanvas.displayName = 'DesignCanvas';
@@ -1239,20 +1326,56 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1239
1326
  // hook is non-idle; SnapGuideOverlay (in canvas-shell) reads guides.
1240
1327
  const [dragCurrent, setDragCurrent] = useState<DragState>({ kind: 'idle' });
1241
1328
 
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 } });
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[] } });
1254
1345
  }, []);
1255
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
+
1256
1379
  const dragBus = useMemo<DragStateBus>(
1257
1380
  () => ({
1258
1381
  current: dragCurrent,
@@ -1348,7 +1471,7 @@ export function DCArtboard({
1348
1471
  children: ReactNode;
1349
1472
  }) {
1350
1473
  const ctx = useWorldContext();
1351
- const controller = useViewportControllerContext();
1474
+ const _controller = useViewportControllerContext();
1352
1475
  const toolMode = useToolModeOptional();
1353
1476
  const selSet = useSelectionSetOptional();
1354
1477
  const dragBus = useDragStateContext();
@@ -1394,9 +1517,11 @@ export function DCArtboard({
1394
1517
  );
1395
1518
  }
1396
1519
  const isActive = ctx.activeArtboardId === id;
1397
- const onFocus = () => {
1398
- if (controller) controller.jumpTo(rect);
1399
- };
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.
1400
1525
 
1401
1526
  // Am I involved in the current drag (as leader or follower)?
1402
1527
  const busDrag = dragBus?.current;
@@ -1431,12 +1556,7 @@ export function DCArtboard({
1431
1556
  style={{ left: liveX, top: liveY, width: rect.w, height: rect.h }}
1432
1557
  {...handleProps}
1433
1558
  >
1434
- <button
1435
- type="button"
1436
- className="dc-artboard-label sku"
1437
- onClick={onFocus}
1438
- aria-label={`Focus artboard ${label}`}
1439
- >
1559
+ <button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
1440
1560
  {label}
1441
1561
  </button>
1442
1562
  <div className="dc-artboard-body">{children}</div>
@@ -2088,3 +2208,359 @@ export function useArtboardBounds(ref: RefObject<HTMLElement | null>): {
2088
2208
  // Re-export `useRef` so `useArtboardBounds` consumers can keep a single
2089
2209
  // import line from `@maude/canvas-lib`.
2090
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
+ };