@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
@@ -0,0 +1,27 @@
1
+ // use-keyboard-discipline — T29 (Wave 3). Pure nudge-delta fixture.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { nudgeDelta } from '../use-keyboard-discipline.tsx';
6
+
7
+ describe('nudgeDelta', () => {
8
+ test('non-arrow key → null', () => {
9
+ expect(nudgeDelta({ key: 'a', shift: false })).toBeNull();
10
+ expect(nudgeDelta({ key: 'Tab', shift: false })).toBeNull();
11
+ expect(nudgeDelta({ key: 'Enter', shift: true })).toBeNull();
12
+ });
13
+
14
+ test('arrow without shift → 1 px step', () => {
15
+ expect(nudgeDelta({ key: 'ArrowLeft', shift: false })).toEqual({ dx: -1, dy: 0 });
16
+ expect(nudgeDelta({ key: 'ArrowRight', shift: false })).toEqual({ dx: 1, dy: 0 });
17
+ expect(nudgeDelta({ key: 'ArrowUp', shift: false })).toEqual({ dx: 0, dy: -1 });
18
+ expect(nudgeDelta({ key: 'ArrowDown', shift: false })).toEqual({ dx: 0, dy: 1 });
19
+ });
20
+
21
+ test('arrow with shift → 10 px step', () => {
22
+ expect(nudgeDelta({ key: 'ArrowLeft', shift: true })).toEqual({ dx: -10, dy: 0 });
23
+ expect(nudgeDelta({ key: 'ArrowRight', shift: true })).toEqual({ dx: 10, dy: 0 });
24
+ expect(nudgeDelta({ key: 'ArrowUp', shift: true })).toEqual({ dx: 0, dy: -10 });
25
+ expect(nudgeDelta({ key: 'ArrowDown', shift: true })).toEqual({ dx: 0, dy: 10 });
26
+ });
27
+ });
@@ -0,0 +1,193 @@
1
+ // use-undo-stack — Provider runner contract. SSR-capture pattern + ref-as-
2
+ // store means most of the interesting state lives outside React renders,
3
+ // so we exercise the action closures directly. Pure reducer is covered in
4
+ // undo-stack.test.ts.
5
+
6
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
7
+
8
+ import { renderToStaticMarkup } from 'react-dom/server';
9
+
10
+ import {
11
+ type CommandRecord,
12
+ type CommandSinks,
13
+ _clearBuilderRegistry,
14
+ _clearStackStore,
15
+ registerCommand,
16
+ } from '../undo-stack.ts';
17
+ import {
18
+ UndoStackProvider,
19
+ useUndoSinks,
20
+ useUndoStack,
21
+ useUndoStackOptional,
22
+ } from '../use-undo-stack.tsx';
23
+
24
+ // Shared spy plumbing for record-builder tests below.
25
+ let doSpy: ReturnType<typeof mock>;
26
+ let undoSpy: ReturnType<typeof mock>;
27
+
28
+ beforeEach(() => {
29
+ _clearStackStore();
30
+ _clearBuilderRegistry();
31
+ doSpy = mock(() => {});
32
+ undoSpy = mock(() => {});
33
+ // A single "test" command kind shared across this file. The builder reads
34
+ // `sinks.layoutPatchFn` as a generic "trigger" — when present, do() and
35
+ // undo() fire the spies; when absent, rebuild returns null.
36
+ registerCommand('test', (record, sinks) => {
37
+ if (!sinks.layoutPatchFn) return null;
38
+ return {
39
+ kind: record.kind,
40
+ label: record.label,
41
+ do() {
42
+ doSpy(record.payload);
43
+ },
44
+ undo() {
45
+ undoSpy(record.payload);
46
+ },
47
+ };
48
+ });
49
+ });
50
+
51
+ afterEach(() => {
52
+ _clearStackStore();
53
+ });
54
+
55
+ function rec(label: string, payload: unknown = null): CommandRecord {
56
+ return { kind: 'test', label, payload };
57
+ }
58
+
59
+ function capture<T>(useHook: () => T, tree: (consumer: React.ReactElement) => React.ReactElement) {
60
+ let captured: T | null = null;
61
+ function Consumer() {
62
+ captured = useHook();
63
+ return null;
64
+ }
65
+ renderToStaticMarkup(tree(<Consumer />));
66
+ if (!captured) throw new Error('hook did not capture a value');
67
+ return captured;
68
+ }
69
+
70
+ /**
71
+ * Render a provider tree and capture both the stack value and the sinks API.
72
+ * Sinks are bound during the same SSR render via a sibling capture, so the
73
+ * runner's first push() sees the registered sink.
74
+ */
75
+ function captureProvider(canvasFile?: string, sinks?: Partial<CommandSinks>) {
76
+ let stack: ReturnType<typeof useUndoStack> | null = null;
77
+ function Inner() {
78
+ stack = useUndoStack();
79
+ const undoSinks = useUndoSinks();
80
+ if (sinks) {
81
+ for (const [k, v] of Object.entries(sinks)) {
82
+ // biome-ignore lint/suspicious/noExplicitAny: setSink generic indexing
83
+ undoSinks.setSink(k as never, v as any);
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ renderToStaticMarkup(
89
+ <UndoStackProvider canvasFile={canvasFile}>
90
+ <Inner />
91
+ </UndoStackProvider>
92
+ );
93
+ if (!stack) throw new Error('no provider value captured');
94
+ return stack;
95
+ }
96
+
97
+ describe('use-undo-stack / contract outside provider', () => {
98
+ test('useUndoStack() throws outside provider', () => {
99
+ function Bare() {
100
+ useUndoStack();
101
+ return null;
102
+ }
103
+ expect(() => renderToStaticMarkup(<Bare />)).toThrow(
104
+ /useUndoStack must be used inside <UndoStackProvider>/
105
+ );
106
+ });
107
+
108
+ test('useUndoStackOptional() returns no-op value outside provider', () => {
109
+ const value = capture(useUndoStackOptional, (child) => <>{child}</>);
110
+ expect(value.canUndo).toBe(false);
111
+ expect(value.canRedo).toBe(false);
112
+ expect(value.lastLabel).toBeNull();
113
+ expect(() => value.clear()).not.toThrow();
114
+ });
115
+
116
+ test('useUndoSinks() outside provider is a silent no-op', () => {
117
+ const value = capture(useUndoSinks, (child) => <>{child}</>);
118
+ expect(() => value.setSink('layoutPatchFn', () => {})).not.toThrow();
119
+ });
120
+ });
121
+
122
+ describe('use-undo-stack / runner side-effects', () => {
123
+ test('push() invokes rebuilt cmd.do() exactly once', async () => {
124
+ const v = captureProvider(undefined, { layoutPatchFn: () => {} });
125
+ await v.push(rec('move 1'));
126
+ expect(doSpy).toHaveBeenCalledTimes(1);
127
+ expect(undoSpy).toHaveBeenCalledTimes(0);
128
+ });
129
+
130
+ test('undo() invokes cmd.undo() on the most recently pushed record', async () => {
131
+ const v = captureProvider(undefined, { layoutPatchFn: () => {} });
132
+ await v.push(rec('a', 'A'));
133
+ await v.push(rec('b', 'B'));
134
+ await v.undo();
135
+ expect(undoSpy).toHaveBeenCalledTimes(1);
136
+ expect(undoSpy.mock.calls[0]?.[0]).toBe('B');
137
+ });
138
+
139
+ test('redo() re-invokes cmd.do() on the most recently undone record', async () => {
140
+ const v = captureProvider(undefined, { layoutPatchFn: () => {} });
141
+ await v.push(rec('a', 'A'));
142
+ await v.undo();
143
+ await v.redo();
144
+ expect(doSpy).toHaveBeenCalledTimes(2);
145
+ expect(doSpy.mock.calls[1]?.[0]).toBe('A');
146
+ });
147
+
148
+ test('push without a registered sink skips the do() call (rebuild returned null)', async () => {
149
+ const v = captureProvider(undefined, {}); // no sinks → builder returns null
150
+ await v.push(rec('lonely'));
151
+ // doSpy is the only observable signal here — `lastLabel` is React
152
+ // state that doesn't refresh under SSR-capture.
153
+ expect(doSpy).toHaveBeenCalledTimes(0);
154
+ expect(undoSpy).toHaveBeenCalledTimes(0);
155
+ });
156
+ });
157
+
158
+ describe('use-undo-stack / cross-canvas persistence', () => {
159
+ test('history under a canvasFile survives a fresh provider mount with the same canvasFile', async () => {
160
+ // First mount: push 2 records.
161
+ {
162
+ const v = captureProvider('ui/Foo.tsx', { layoutPatchFn: () => {} });
163
+ await v.push(rec('e1'));
164
+ await v.push(rec('e2'));
165
+ }
166
+ // Second mount (simulates iframe destroy + remount for same canvas).
167
+ const v2 = captureProvider('ui/Foo.tsx', { layoutPatchFn: () => {} });
168
+ expect(v2.canUndo).toBe(true);
169
+ expect(v2.lastLabel).toBeNull(); // labels are per-mount session
170
+ // Undo the top — should fire spy with rec('e2') payload.
171
+ await v2.undo();
172
+ expect(undoSpy).toHaveBeenCalledTimes(1);
173
+ });
174
+
175
+ test('switching canvases keeps each history independent', async () => {
176
+ {
177
+ const v = captureProvider('ui/Foo.tsx', { layoutPatchFn: () => {} });
178
+ await v.push(rec('foo-1'));
179
+ await v.push(rec('foo-2'));
180
+ }
181
+ {
182
+ const v = captureProvider('ui/Bar.tsx', { layoutPatchFn: () => {} });
183
+ await v.push(rec('bar-1'));
184
+ }
185
+ // Come back to Foo.
186
+ const v2 = captureProvider('ui/Foo.tsx', { layoutPatchFn: () => {} });
187
+ expect(v2.canUndo).toBe(true);
188
+ await v2.undo();
189
+ // The spy was called for foo-2 last (LIFO).
190
+ const lastCall = undoSpy.mock.calls[undoSpy.mock.calls.length - 1]?.[0];
191
+ expect(lastCall).toBeNull(); // payload is null in `rec(label)` default
192
+ });
193
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file undo-hud.tsx — top-right toast announcing the last undo edit
3
+ * @scope plugins/design/dev-server/undo-hud.tsx
4
+ * @purpose Subscribes to `useUndoStack().lastLabel` + `.lastTick` and
5
+ * surfaces a 1.2 s aria-live toast so the user (and a screen
6
+ * reader) knows which edit just got pushed / undone / redone.
7
+ *
8
+ * a11y. The toast renders into `aria-live="polite"` so a screen reader
9
+ * announces "Undo: move 3 artboards" without interrupting in-flight speech.
10
+ * Pointer events are disabled — the HUD is informational only and must
11
+ * never absorb clicks meant for the canvas behind it.
12
+ */
13
+
14
+ import { useEffect, useState } from 'react';
15
+
16
+ import { useUndoStackOptional } from './use-undo-stack.tsx';
17
+
18
+ const HUD_CSS = `
19
+ .dc-undo-hud {
20
+ position: fixed;
21
+ top: 16px;
22
+ right: 16px;
23
+ z-index: 7;
24
+ pointer-events: none;
25
+ padding: 6px 10px;
26
+ background: var(--bg-2, rgba(20, 20, 20, 0.85));
27
+ color: var(--fg-1, rgba(255, 255, 255, 0.85));
28
+ border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
29
+ border-radius: 6px;
30
+ font: 11px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
31
+ letter-spacing: 0.02em;
32
+ user-select: none;
33
+ opacity: 0;
34
+ transform: translateY(-4px);
35
+ transition: opacity var(--dur-base, 200ms) linear, transform var(--dur-base, 200ms) ease-out;
36
+ }
37
+ .dc-undo-hud[data-visible="true"] {
38
+ opacity: 1;
39
+ transform: translateY(0);
40
+ transition-duration: var(--dur-fast, 120ms);
41
+ }
42
+ @media (prefers-reduced-motion: reduce) {
43
+ .dc-undo-hud {
44
+ transition-duration: 1ms !important;
45
+ }
46
+ }
47
+ `.trim();
48
+
49
+ function ensureStyles(): void {
50
+ if (typeof document === 'undefined') return;
51
+ if (document.getElementById('dc-undo-hud-css')) return;
52
+ const s = document.createElement('style');
53
+ s.id = 'dc-undo-hud-css';
54
+ s.textContent = HUD_CSS;
55
+ document.head.appendChild(s);
56
+ }
57
+
58
+ /** Auto-dismiss timeout — matches the post-Wave-3 toast cadence. */
59
+ const DISMISS_MS = 1200;
60
+
61
+ export function UndoHud() {
62
+ ensureStyles();
63
+ const undo = useUndoStackOptional();
64
+ const [visible, setVisible] = useState(false);
65
+
66
+ // Bump visibility every time `lastTick` increments — same tick value means
67
+ // the underlying state did NOT change (provider's no-op fallback returns
68
+ // `lastTick: 0` constantly, so the HUD stays hidden when no provider).
69
+ const tick = undo.lastTick;
70
+ const label = undo.lastLabel;
71
+
72
+ useEffect(() => {
73
+ if (tick === 0 || !label) {
74
+ setVisible(false);
75
+ return;
76
+ }
77
+ setVisible(true);
78
+ const id = window.setTimeout(() => setVisible(false), DISMISS_MS);
79
+ return () => window.clearTimeout(id);
80
+ }, [tick, label]);
81
+
82
+ return (
83
+ <div
84
+ className="dc-undo-hud"
85
+ // biome-ignore lint/a11y/useSemanticElements: <output> is form-scoped; this HUD is a global status overlay outside any form
86
+ data-visible={visible ? 'true' : 'false'}
87
+ role="status"
88
+ aria-live="polite"
89
+ aria-atomic="true"
90
+ >
91
+ {label ?? ''}
92
+ </div>
93
+ );
94
+ }
95
+ UndoHud.displayName = 'UndoHud';
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @file undo-stack.ts — per-canvas in-memory command stack
3
+ * @scope plugins/design/dev-server/undo-stack.ts
4
+ * @purpose Pure state + reducer for canvas Cmd+Z / Cmd+Shift+Z. No React,
5
+ * no DOM, no fetch — `EditCommand.do()` / `.undo()` are caller-
6
+ * supplied side-effects so this file stays trivially testable
7
+ * under `bun:test`.
8
+ *
9
+ * Scope (DDR-050 rev 2):
10
+ * - **Persistent per-canvas, in-memory, session-scoped.** The stack lives
11
+ * in `window.top.__maude_undo_stacks` keyed by canvas file path so it
12
+ * survives canvas switches (close Foo.tsx, open Bar.tsx, come back to
13
+ * Foo.tsx → history still there). Reload destroys it. The original
14
+ * "per-iframe-ephemeral" rule from rev 1 was UX-wrong.
15
+ * - Stack stores SERIALIZABLE `CommandRecord`s (kind + label + payload),
16
+ * NOT EditCommand closures. Closures captured by patchFn/putFn point
17
+ * to the iframe's React state — when that iframe unmounts (canvas
18
+ * switch), the closures become invalid. Rebuilding from the record
19
+ * using the fresh iframe's sinks side-steps the lifecycle issue.
20
+ * - Command-pattern semantics intact: each kind ships its own builder
21
+ * that turns a payload + sinks into a runnable EditCommand.
22
+ * - Depth cap = 50 per canvas. Future-discarded on push. Viewport +
23
+ * selection NOT undoable (Figma convention).
24
+ *
25
+ * Async note. `cmd.do()` and `cmd.undo()` may return a Promise (server
26
+ * PATCH/PUT). The reducer itself is synchronous and never awaits — the
27
+ * runner inside `use-undo-stack.tsx` awaits the side-effect BEFORE
28
+ * dispatching the state transition. Reducer-pure keeps reasoning + tests
29
+ * trivial.
30
+ */
31
+
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // Runtime command shape
34
+
35
+ /**
36
+ * A single reversible edit, ready to execute. Built on demand from a
37
+ * `CommandRecord` + `CommandSinks` via the registry below. Holds closures
38
+ * bound to the CURRENT iframe's React state — never persist this; persist
39
+ * the `CommandRecord` instead.
40
+ */
41
+ export interface EditCommand {
42
+ readonly kind: string;
43
+ readonly label: string;
44
+ do(): Promise<void> | void;
45
+ undo(): Promise<void> | void;
46
+ }
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // Persistent record shape — what the stack actually stores
50
+
51
+ /**
52
+ * Serializable description of a reversible edit. Each `kind` corresponds
53
+ * to a registered builder (see `registerCommand` below). Payload shape is
54
+ * the kind's contract — see `commands/*-command.ts` for definitions.
55
+ */
56
+ export interface CommandRecord<P = unknown> {
57
+ readonly kind: string;
58
+ readonly label: string;
59
+ readonly payload: P;
60
+ }
61
+
62
+ /**
63
+ * Per-iframe side-effect surface. Populated by descendants of the
64
+ * `UndoStackProvider` via `useUndoSinks().setSinks(...)`. Each consumer
65
+ * provides only the sink it owns; the provider merges. Unbound sinks are
66
+ * `undefined` — builders fail gracefully (returning `null` makes the
67
+ * runner skip the entry with a warning rather than crash).
68
+ */
69
+ export interface CommandSinks {
70
+ /** Wired by canvas-lib `DesignCanvasInner`. */
71
+ layoutPatchFn?: unknown;
72
+ /** Wired by `AnnotationsLayer`. */
73
+ strokesPutFn?: unknown;
74
+ }
75
+
76
+ export type CommandBuilder<P = unknown> = (
77
+ record: CommandRecord<P>,
78
+ sinks: CommandSinks
79
+ ) => EditCommand | null;
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // Reducer state + actions
83
+
84
+ export interface UndoStackState {
85
+ past: readonly CommandRecord[];
86
+ future: readonly CommandRecord[];
87
+ }
88
+
89
+ export type UndoAction =
90
+ | { type: 'push'; record: CommandRecord }
91
+ | { type: 'undo' }
92
+ | { type: 'redo' }
93
+ | { type: 'clear' }
94
+ | { type: 'hydrate'; state: UndoStackState };
95
+
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+ // Constants
98
+
99
+ /**
100
+ * Ring cap per canvas. When `past.length === MAX_DEPTH` and a new record
101
+ * arrives, the oldest entry is dropped. 50 ≈ 3–5 minutes of intense
102
+ * iteration before the user can no longer undo to the start.
103
+ */
104
+ export const MAX_DEPTH = 50;
105
+
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+ // Builder registry
108
+
109
+ const BUILDERS = new Map<string, CommandBuilder>();
110
+
111
+ /** Register a builder for a command kind. Idempotent. */
112
+ export function registerCommand<P>(kind: string, builder: CommandBuilder<P>): void {
113
+ BUILDERS.set(kind, builder as CommandBuilder);
114
+ }
115
+
116
+ /**
117
+ * Rebuild a runnable `EditCommand` from a persisted record + the iframe's
118
+ * current sinks. Returns `null` when the kind isn't registered or the
119
+ * required sink isn't bound — the runner treats `null` as "skip this entry,
120
+ * something's misconfigured" rather than crashing.
121
+ */
122
+ export function rebuildCommand(record: CommandRecord, sinks: CommandSinks): EditCommand | null {
123
+ const builder = BUILDERS.get(record.kind);
124
+ if (!builder) return null;
125
+ return builder(record, sinks);
126
+ }
127
+
128
+ /** Test seam — reset the registry between bun:test cases. */
129
+ export function _clearBuilderRegistry(): void {
130
+ BUILDERS.clear();
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ // Factory + reducer
135
+
136
+ export function createUndoStackState(): UndoStackState {
137
+ return { past: [], future: [] };
138
+ }
139
+
140
+ export function undoReducer(state: UndoStackState, action: UndoAction): UndoStackState {
141
+ switch (action.type) {
142
+ case 'push': {
143
+ const next = state.past.length >= MAX_DEPTH ? state.past.slice(1) : state.past;
144
+ return { past: [...next, action.record], future: [] };
145
+ }
146
+ case 'undo': {
147
+ if (state.past.length === 0) return state;
148
+ const top = state.past[state.past.length - 1];
149
+ if (!top) return state;
150
+ return {
151
+ past: state.past.slice(0, -1),
152
+ future: [...state.future, top],
153
+ };
154
+ }
155
+ case 'redo': {
156
+ if (state.future.length === 0) return state;
157
+ const top = state.future[state.future.length - 1];
158
+ if (!top) return state;
159
+ return {
160
+ past: [...state.past, top],
161
+ future: state.future.slice(0, -1),
162
+ };
163
+ }
164
+ case 'clear':
165
+ return createUndoStackState();
166
+ case 'hydrate':
167
+ return action.state;
168
+ }
169
+ }
170
+
171
+ // ─────────────────────────────────────────────────────────────────────────────
172
+ // Selectors — keep state-shape inspection out of consumers.
173
+
174
+ export function canUndo(state: UndoStackState): boolean {
175
+ return state.past.length > 0;
176
+ }
177
+
178
+ export function canRedo(state: UndoStackState): boolean {
179
+ return state.future.length > 0;
180
+ }
181
+
182
+ /** Top of `past` — the record an `undo` will invert. */
183
+ export function peekUndo(state: UndoStackState): CommandRecord | null {
184
+ return state.past[state.past.length - 1] ?? null;
185
+ }
186
+
187
+ /** Top of `future` — the record a `redo` will re-apply. */
188
+ export function peekRedo(state: UndoStackState): CommandRecord | null {
189
+ return state.future[state.future.length - 1] ?? null;
190
+ }
191
+
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+ // Cross-iframe persistent store (DDR-050 rev 2)
194
+ //
195
+ // Lives on the topmost window of the dev-server (same-origin — all canvas
196
+ // iframes are children of /index.html). The map is keyed by canvas file path,
197
+ // so switching from Foo.tsx → Bar.tsx → Foo.tsx replays the original Foo
198
+ // history into the freshly-mounted iframe.
199
+
200
+ interface StoreHost {
201
+ __maude_undo_stacks?: Map<string, UndoStackState>;
202
+ }
203
+
204
+ /**
205
+ * Prefer `window.top` so all canvas iframes (children of the dev-server
206
+ * shell) read + write the same Map. Falls back to `window` (when top is
207
+ * cross-origin — shouldn't happen in our same-origin setup), then to
208
+ * `globalThis` (Node / Bun test runtime where window is absent).
209
+ */
210
+ function getStoreHost(): StoreHost {
211
+ if (typeof window !== 'undefined') {
212
+ try {
213
+ return (window.top ?? window) as unknown as StoreHost;
214
+ } catch {
215
+ return window as unknown as StoreHost;
216
+ }
217
+ }
218
+ return globalThis as unknown as StoreHost;
219
+ }
220
+
221
+ function ensureStoreMap(): Map<string, UndoStackState> {
222
+ const host = getStoreHost();
223
+ if (!host.__maude_undo_stacks) host.__maude_undo_stacks = new Map();
224
+ return host.__maude_undo_stacks;
225
+ }
226
+
227
+ /** Load saved state for a canvas file. Returns empty state on miss. */
228
+ export function loadStackState(canvasFile: string): UndoStackState {
229
+ return ensureStoreMap().get(canvasFile) ?? createUndoStackState();
230
+ }
231
+
232
+ /** Persist state for a canvas file. */
233
+ export function saveStackState(canvasFile: string, state: UndoStackState): void {
234
+ ensureStoreMap().set(canvasFile, state);
235
+ }
236
+
237
+ /** Test seam — wipe the cross-iframe store between bun:test cases. */
238
+ export function _clearStackStore(): void {
239
+ getStoreHost().__maude_undo_stacks = new Map();
240
+ }
@@ -36,14 +36,17 @@ import {
36
36
  useState,
37
37
  } from 'react';
38
38
 
39
+ import { DRAG_THRESHOLD_PX as INPUT_DRAG_THRESHOLD_PX } from './input-router.tsx';
39
40
  import type { Selection } from './use-selection-set.tsx';
40
41
  import { type Rect, type SnapResult, computeSnap } from './use-snap-guides.tsx';
41
42
 
42
43
  // ─────────────────────────────────────────────────────────────────────────────
43
44
  // Constants
44
45
 
45
- /** Screen-pixel distance the cursor must travel before pending → dragging. */
46
- export const DRAG_THRESHOLD_PX = 4;
46
+ /** Screen-pixel distance the cursor must travel before pending → dragging.
47
+ * Re-export from `input-router` so artboard-drag, marquees, and annotation
48
+ * drag-vs-tap share the same canonical value (T25). */
49
+ export const DRAG_THRESHOLD_PX = INPUT_DRAG_THRESHOLD_PX;
47
50
 
48
51
  /** Default grid + tolerance (world units). Documented in DDR-028. */
49
52
  export const DEFAULT_GRID_SIZE = 40;
@@ -136,7 +139,7 @@ export function dragReducer(state: DragState, ev: DragEvent): DragState {
136
139
  const dxClient = ev.clientX - state.startClientX;
137
140
  const dyClient = ev.clientY - state.startClientY;
138
141
  if (state.kind === 'pending') {
139
- if (Math.abs(dxClient) < DRAG_THRESHOLD_PX && Math.abs(dyClient) < DRAG_THRESHOLD_PX) {
142
+ if (Math.hypot(dxClient, dyClient) < DRAG_THRESHOLD_PX) {
140
143
  return state;
141
144
  }
142
145
  }