@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
|
@@ -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-
|
|
38
|
+
background: var(--u-bg-0, var(--bg-0, #ffffff));
|
|
36
39
|
border: 1px solid var(--u-fg-0, #1c1917);
|
|
37
|
-
border-radius:
|
|
38
|
-
box-shadow:
|
|
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.
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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-
|
|
148
|
+
background: var(--u-bg-0, var(--bg-0, #ffffff));
|
|
102
149
|
border: 1px solid var(--u-fg-0, #1c1917);
|
|
103
|
-
border-radius:
|
|
104
|
-
box-shadow:
|
|
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
|
-
|
|
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
|
+
}
|