@1agh/maude 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
  3. package/plugins/design/dev-server/annotations-layer.tsx +343 -112
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
  6. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  7. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +691 -119
  9. package/plugins/design/dev-server/canvas-shell.tsx +818 -42
  10. package/plugins/design/dev-server/client/app.jsx +173 -52
  11. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  12. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  13. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  14. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  15. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  16. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  17. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  18. package/plugins/design/dev-server/config.schema.json +31 -5
  19. package/plugins/design/dev-server/context-menu.tsx +3 -3
  20. package/plugins/design/dev-server/context.ts +37 -3
  21. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  22. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  23. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  24. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  25. package/plugins/design/dev-server/dist/styles.css +38 -2
  26. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  27. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  28. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  29. package/plugins/design/dev-server/handoff.ts +24 -0
  30. package/plugins/design/dev-server/http.ts +41 -2
  31. package/plugins/design/dev-server/input-router.tsx +74 -10
  32. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  33. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  34. package/plugins/design/dev-server/server.mjs +84 -18
  35. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  36. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  37. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  38. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  39. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  40. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  41. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  42. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  43. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  44. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  45. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  46. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  47. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  48. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  49. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  50. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  51. package/plugins/design/dev-server/undo-stack.ts +240 -0
  52. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  53. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  54. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  55. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  56. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  57. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
  58. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  59. package/plugins/design/templates/_shell.html +17 -6
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  61. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  62. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  63. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  64. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  65. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  66. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -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
+ });
@@ -24,6 +24,9 @@ import { useToolMode } from './use-tool-mode.tsx';
24
24
  // 32 × 32 buttons with iconography. Grouped pill segments separated by 1 px
25
25
  // dividers — visual cue that nav / draw / view-controls are distinct kinds.
26
26
 
27
+ // DDR-046 — floating chrome ambient shadow + 8 px radius (NOT brutalist).
28
+ // Active-tool state: 14% tint bg + 2px accent underbar + accent fg, replacing
29
+ // the previous full-accent flood (too loud at 32 × 32 in a quiet chrome).
27
30
  const PALETTE_CSS = `
28
31
  .dc-tool-palette {
29
32
  position: absolute;
@@ -32,10 +35,10 @@ const PALETTE_CSS = `
32
35
  transform: translateX(-50%);
33
36
  display: flex;
34
37
  align-items: stretch;
35
- background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
38
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
36
39
  border: 1px solid var(--u-fg-0, #1c1917);
37
- border-radius: 0;
38
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
40
+ border-radius: 8px;
41
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
39
42
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
40
43
  font-size: 12px;
41
44
  color: var(--u-fg-0, var(--fg-0, #1a1a1a));
@@ -43,8 +46,7 @@ const PALETTE_CSS = `
43
46
  user-select: none;
44
47
  /* Intentionally NO overflow:hidden — the zoom popover (.dc-tp-popover) is
45
48
  a position:absolute child anchored above the toolbar; clipping the
46
- parent would hide it. Inner buttons have their own border-radius so the
47
- rounded outer corners are preserved by content shape, not overflow. */
49
+ parent would hide it. */
48
50
  }
49
51
  .dc-tool-palette .dc-tp-group {
50
52
  display: flex;
@@ -61,7 +63,7 @@ const PALETTE_CSS = `
61
63
  appearance: none;
62
64
  background: transparent;
63
65
  border: 0;
64
- border-radius: 0;
66
+ border-radius: 6px;
65
67
  padding: 0;
66
68
  width: 32px;
67
69
  height: 32px;
@@ -70,16 +72,61 @@ const PALETTE_CSS = `
70
72
  justify-content: center;
71
73
  color: var(--fg-1, rgba(40,30,20,0.75));
72
74
  cursor: pointer;
75
+ position: relative;
73
76
  transition: background-color 80ms linear, color 80ms linear;
74
77
  }
75
- .dc-tool-palette button:hover { background: rgba(0,0,0,0.04); color: var(--fg-0, #1a1a1a); }
78
+ .dc-tool-palette button:hover {
79
+ background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
80
+ color: var(--fg-0, #1a1a1a);
81
+ }
76
82
  .dc-tool-palette button:focus-visible {
77
83
  outline: 2px solid var(--accent, #d63b1f);
78
84
  outline-offset: -2px;
79
85
  }
86
+ /* DDR-046 — Active tool: tinted background + accent underbar + accent text.
87
+ The underbar is rendered via ::after so the visual stays inside the 6 px
88
+ radius without leaking past the button edge. */
80
89
  .dc-tool-palette button[aria-pressed="true"] {
90
+ background: color-mix(in oklab, var(--accent, #d63b1f) 14%, transparent);
91
+ color: var(--accent, #d63b1f);
92
+ }
93
+ .dc-tool-palette button[aria-pressed="true"]::after {
94
+ content: "";
95
+ position: absolute;
96
+ left: 6px;
97
+ right: 6px;
98
+ bottom: 2px;
99
+ height: 2px;
81
100
  background: var(--accent, #d63b1f);
82
- color: var(--accent-fg, #fff);
101
+ border-radius: 1px;
102
+ }
103
+ /* T19 — sticky-tool lock badge. Tiny accent square in the top-right corner
104
+ of the active tool button, fades in 50 ms after a double-click. The badge
105
+ reads "armed across draws" so the user understands the tool will stay
106
+ selected after each shape commit. */
107
+ .dc-tool-palette button[data-sticky="true"] {
108
+ /* Keep the tinted active background; add a hairline ring so the lock state
109
+ is readable even when the button is also aria-pressed. */
110
+ box-shadow: inset 0 0 0 1px var(--accent, #d63b1f);
111
+ }
112
+ .dc-tool-palette button .dc-tp-sticky-badge {
113
+ position: absolute;
114
+ top: 3px;
115
+ right: 3px;
116
+ width: 6px;
117
+ height: 6px;
118
+ background: var(--accent, #d63b1f);
119
+ border-radius: 1px;
120
+ box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
121
+ opacity: 0;
122
+ transition: opacity 50ms linear 50ms;
123
+ pointer-events: none;
124
+ }
125
+ .dc-tool-palette button[data-sticky="true"] .dc-tp-sticky-badge {
126
+ opacity: 1;
127
+ }
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .dc-tool-palette button .dc-tp-sticky-badge { transition: none; }
83
130
  }
84
131
  .dc-tool-palette .dc-tp-zoom {
85
132
  min-width: 56px;
@@ -88,7 +135,7 @@ const PALETTE_CSS = `
88
135
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
89
136
  font-size: 11px;
90
137
  letter-spacing: 0.04em;
91
- border-radius: 0;
138
+ border-radius: 6px;
92
139
  display: inline-flex;
93
140
  align-items: center;
94
141
  gap: 4px;
@@ -98,10 +145,10 @@ const PALETTE_CSS = `
98
145
  position: absolute;
99
146
  right: 4px;
100
147
  bottom: 44px;
101
- background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
148
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
102
149
  border: 1px solid var(--u-fg-0, #1c1917);
103
- border-radius: 0;
104
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
150
+ border-radius: 8px;
151
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
105
152
  display: flex;
106
153
  flex-direction: column;
107
154
  padding: 4px;
@@ -149,7 +196,7 @@ const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'arrow', 'eraser'] as const;
149
196
 
150
197
  export function ToolPalette() {
151
198
  ensurePaletteStyles();
152
- const { tool, setTool, tools } = useToolMode();
199
+ const { tool, setTool, tools, sticky, toggleSticky } = useToolMode();
153
200
  const controller = useViewportControllerContext();
154
201
  const visibilityCtx = useAnnotationsVisibility();
155
202
  const [mounted, setMounted] = useState(false);
@@ -176,16 +223,25 @@ export function ToolPalette() {
176
223
 
177
224
  const renderToolButton = (id: string, label: string, shortcut: string) => {
178
225
  const Icon = TOOL_ICONS[id];
226
+ const isDraw = (DRAW_TOOLS as readonly string[]).includes(id);
227
+ const isSticky = sticky.locked && sticky.tool === (id as never);
228
+ const stickyHint = isDraw ? ' · double-click to lock' : '';
179
229
  return (
180
230
  <button
181
231
  key={id}
182
232
  type="button"
183
- aria-label={`${label} (${shortcut})`}
233
+ aria-label={`${label} (${shortcut})${isSticky ? ' — locked' : stickyHint}`}
184
234
  aria-pressed={tool === id}
185
- title={`${label} (${shortcut})`}
235
+ data-sticky={isSticky ? 'true' : undefined}
236
+ title={`${label} (${shortcut})${stickyHint}`}
186
237
  onClick={() => setTool(id as never)}
238
+ onDoubleClick={() => {
239
+ if (!isDraw) return;
240
+ toggleSticky(id as never);
241
+ }}
187
242
  >
188
243
  {Icon ? <Icon /> : <span style={{ fontSize: 11 }}>{shortcut}</span>}
244
+ {isSticky ? <span className="dc-tp-sticky-badge" aria-hidden="true" /> : null}
189
245
  </button>
190
246
  );
191
247
  };
@@ -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
+ }