@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.
- package/package.json +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
- package/plugins/design/dev-server/annotations-layer.tsx +343 -112
- package/plugins/design/dev-server/api.ts +70 -17
- package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
- 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 +691 -119
- package/plugins/design/dev-server/canvas-shell.tsx +818 -42
- package/plugins/design/dev-server/client/app.jsx +173 -52
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
- package/plugins/design/dev-server/client/styles/4-components.css +32 -2
- 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/config.schema.json +31 -5
- package/plugins/design/dev-server/context-menu.tsx +3 -3
- package/plugins/design/dev-server/context.ts +37 -3
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +212 -103
- 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/dist/styles.css +38 -2
- 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/export-dialog.tsx +3 -3
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +41 -2
- package/plugins/design/dev-server/input-router.tsx +74 -10
- 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/server.mjs +84 -18
- 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/context-resolve-tokens.test.ts +97 -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/snap-distance-pill.test.ts +68 -0
- package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -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/tool-palette.tsx +71 -15
- 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-annotation-resize.tsx +295 -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-snap-guides.tsx +25 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
- 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
|
@@ -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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
-
<
|
|
1039
|
-
<
|
|
1040
|
-
|
|
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
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
|
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
|
-
|
|
1347
|
-
|
|
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
|
-
//
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1364
|
-
|
|
1541
|
+
liveX = busDrag.leaderRect.x;
|
|
1542
|
+
liveY = busDrag.leaderRect.y;
|
|
1365
1543
|
} else if (isFollower && followerOffset) {
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
>
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
<
|
|
1604
|
+
<Fragment
|
|
1430
1605
|
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
|
|
1431
1606
|
key={`x-${i}`}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
<
|
|
1639
|
+
<Fragment
|
|
1452
1640
|
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
|
|
1453
1641
|
key={`y-${i}`}
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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-
|
|
1702
|
+
background: var(--bg-0, #ffffff);
|
|
1495
1703
|
border: 1px solid var(--fg-0, #1c1917);
|
|
1496
|
-
border-radius:
|
|
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:
|
|
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:
|
|
1522
|
-
border: 1px solid
|
|
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
|
-
|
|
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-
|
|
1751
|
+
background: var(--bg-0, #ffffff);
|
|
1537
1752
|
border: 1px solid var(--fg-0, #1c1917);
|
|
1538
|
-
border-radius:
|
|
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:
|
|
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:
|
|
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
|
+
};
|