@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.
- package/package.json +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +112 -38
- package/plugins/design/dev-server/annotations-layer.tsx +204 -95
- package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +506 -30
- package/plugins/design/dev-server/canvas-shell.tsx +352 -20
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +12 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +27 -0
- package/plugins/design/dev-server/input-router.tsx +52 -2
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/runtime-bundle.ts +7 -0
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/templates/_shell.html +17 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- /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
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
-
<
|
|
1090
|
-
<
|
|
1091
|
-
|
|
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
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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
|
|
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
|
-
|
|
1398
|
-
|
|
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
|
+
};
|