@1agh/maude 0.19.1 → 0.21.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/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +17 -9
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
- package/plugins/design/dev-server/annotations-layer.tsx +256 -107
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +534 -32
- package/plugins/design/dev-server/canvas-shell.tsx +479 -29
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +41 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +91 -1
- package/plugins/design/dev-server/input-router.tsx +52 -2
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +15 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -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/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -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 +7 -7
- 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 +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +51 -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/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Unit: pure helpers from use-collab.tsx (color hash + slug derivation).
|
|
2
|
+
// The provider itself + the WS round-trip live in browser-shaped harnesses.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { canvasSlugFromPath, colorForName } from '../use-collab.tsx';
|
|
7
|
+
|
|
8
|
+
describe('colorForName', () => {
|
|
9
|
+
test('returns a color from the curated palette', () => {
|
|
10
|
+
const color = colorForName('Alice');
|
|
11
|
+
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('deterministic per input', () => {
|
|
15
|
+
const a = colorForName('Alice');
|
|
16
|
+
const b = colorForName('Alice');
|
|
17
|
+
expect(a).toBe(b);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('different names land on different colors (probabilistically)', () => {
|
|
21
|
+
// Sample many distinct names; expect at least 8 distinct colors among 30
|
|
22
|
+
// names. With 12-color palette + uniform-ish djb2, this is comfortable.
|
|
23
|
+
const names = Array.from({ length: 30 }, (_, i) => `peer-${i}`);
|
|
24
|
+
const colors = new Set(names.map(colorForName));
|
|
25
|
+
expect(colors.size).toBeGreaterThanOrEqual(8);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('empty string returns a palette color (not crash)', () => {
|
|
29
|
+
expect(colorForName('')).toMatch(/^#[0-9a-f]{6}$/i);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('unicode names work', () => {
|
|
33
|
+
expect(colorForName('Michał Dovrtěl')).toMatch(/^#[0-9a-f]{6}$/i);
|
|
34
|
+
expect(colorForName('佐藤')).toMatch(/^#[0-9a-f]{6}$/i);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('canvasSlugFromPath', () => {
|
|
39
|
+
test('normalizes a typical canvas path', () => {
|
|
40
|
+
expect(canvasSlugFromPath('ui/Foo.tsx')).toBe('ui-foo');
|
|
41
|
+
expect(canvasSlugFromPath('ui/Canvas Viewport.tsx')).toBe('ui-canvas_viewport');
|
|
42
|
+
expect(canvasSlugFromPath('system/project/preview/colors-accent.tsx')).toBe(
|
|
43
|
+
'system-project-preview-colors-accent'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('strips .tsx + .html extensions case-insensitively', () => {
|
|
48
|
+
expect(canvasSlugFromPath('ui/Foo.TSX')).toBe('ui-foo');
|
|
49
|
+
expect(canvasSlugFromPath('ui/Foo.html')).toBe('ui-foo');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('returns null for paths that would yield invalid slug chars', () => {
|
|
53
|
+
// Dots in name (not extension) survive normalization and fail the gate.
|
|
54
|
+
expect(canvasSlugFromPath('ui/Foo.Bar.tsx')).toBeNull();
|
|
55
|
+
// Empty input.
|
|
56
|
+
expect(canvasSlugFromPath('')).toBeNull();
|
|
57
|
+
expect(canvasSlugFromPath(null)).toBeNull();
|
|
58
|
+
expect(canvasSlugFromPath(undefined)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('round-trips against the slug grammar parseCollabSlug accepts', () => {
|
|
62
|
+
// parseCollabSlug regex = ^[a-z0-9_-]+$
|
|
63
|
+
const re = /^[a-z0-9_-]+$/;
|
|
64
|
+
const samples = ['ui/Foo.tsx', 'system/project/preview/x.tsx', 'a/b c.tsx'];
|
|
65
|
+
for (const s of samples) {
|
|
66
|
+
const slug = canvasSlugFromPath(s);
|
|
67
|
+
expect(slug).not.toBeNull();
|
|
68
|
+
if (slug !== null) expect(re.test(slug)).toBe(true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// use-cursor-modifiers — T28 (Wave 3). Pure reducer covers the cross-platform
|
|
2
|
+
// Ctrl≡Meta normalization + same-state short-circuit.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { type ModifierState, reduceModifiers } from '../use-cursor-modifiers.tsx';
|
|
7
|
+
|
|
8
|
+
const initial: ModifierState = { alt: false, shift: false, meta: false };
|
|
9
|
+
|
|
10
|
+
const k = (over: Partial<KeyboardEventInit & { ctrlKey: boolean }> = {}) => ({
|
|
11
|
+
altKey: false,
|
|
12
|
+
shiftKey: false,
|
|
13
|
+
metaKey: false,
|
|
14
|
+
ctrlKey: false,
|
|
15
|
+
...over,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('reduceModifiers', () => {
|
|
19
|
+
test('no change → returns same reference (cheap equality)', () => {
|
|
20
|
+
const out = reduceModifiers(initial, k());
|
|
21
|
+
expect(out).toBe(initial);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('alt down → alt:true', () => {
|
|
25
|
+
const out = reduceModifiers(initial, k({ altKey: true }));
|
|
26
|
+
expect(out).toEqual({ alt: true, shift: false, meta: false });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('shift down → shift:true', () => {
|
|
30
|
+
const out = reduceModifiers(initial, k({ shiftKey: true }));
|
|
31
|
+
expect(out).toEqual({ alt: false, shift: true, meta: false });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('meta down → meta:true', () => {
|
|
35
|
+
const out = reduceModifiers(initial, k({ metaKey: true }));
|
|
36
|
+
expect(out).toEqual({ alt: false, shift: false, meta: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('ctrl is normalized to meta (cross-platform parity)', () => {
|
|
40
|
+
const out = reduceModifiers(initial, k({ ctrlKey: true }));
|
|
41
|
+
expect(out.meta).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('meta + ctrl together → meta:true (not duplicated)', () => {
|
|
45
|
+
const out = reduceModifiers(initial, k({ metaKey: true, ctrlKey: true }));
|
|
46
|
+
expect(out).toEqual({ alt: false, shift: false, meta: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('alt+shift simultaneously → both true', () => {
|
|
50
|
+
const out = reduceModifiers(initial, k({ altKey: true, shiftKey: true }));
|
|
51
|
+
expect(out).toEqual({ alt: true, shift: true, meta: false });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('releasing alt while shift still held → shift only', () => {
|
|
55
|
+
const both: ModifierState = { alt: true, shift: true, meta: false };
|
|
56
|
+
const out = reduceModifiers(both, k({ shiftKey: true })); // alt released
|
|
57
|
+
expect(out).toEqual({ alt: false, shift: true, meta: false });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -76,19 +76,19 @@ const PALETTE_CSS = `
|
|
|
76
76
|
transition: background-color 80ms linear, color 80ms linear;
|
|
77
77
|
}
|
|
78
78
|
.dc-tool-palette button:hover {
|
|
79
|
-
background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
|
|
79
|
+
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
80
80
|
color: var(--fg-0, #1a1a1a);
|
|
81
81
|
}
|
|
82
82
|
.dc-tool-palette button:focus-visible {
|
|
83
|
-
outline: 2px solid var(--accent, #d63b1f);
|
|
83
|
+
outline: 2px solid var(--maude-hud-accent, #d63b1f);
|
|
84
84
|
outline-offset: -2px;
|
|
85
85
|
}
|
|
86
86
|
/* DDR-046 — Active tool: tinted background + accent underbar + accent text.
|
|
87
87
|
The underbar is rendered via ::after so the visual stays inside the 6 px
|
|
88
88
|
radius without leaking past the button edge. */
|
|
89
89
|
.dc-tool-palette button[aria-pressed="true"] {
|
|
90
|
-
background: color-mix(in oklab, var(--accent, #d63b1f) 14%, transparent);
|
|
91
|
-
color: var(--accent, #d63b1f);
|
|
90
|
+
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
|
|
91
|
+
color: var(--maude-hud-accent, #d63b1f);
|
|
92
92
|
}
|
|
93
93
|
.dc-tool-palette button[aria-pressed="true"]::after {
|
|
94
94
|
content: "";
|
|
@@ -97,7 +97,7 @@ const PALETTE_CSS = `
|
|
|
97
97
|
right: 6px;
|
|
98
98
|
bottom: 2px;
|
|
99
99
|
height: 2px;
|
|
100
|
-
background: var(--accent, #d63b1f);
|
|
100
|
+
background: var(--maude-hud-accent, #d63b1f);
|
|
101
101
|
border-radius: 1px;
|
|
102
102
|
}
|
|
103
103
|
/* T19 — sticky-tool lock badge. Tiny accent square in the top-right corner
|
|
@@ -107,7 +107,7 @@ const PALETTE_CSS = `
|
|
|
107
107
|
.dc-tool-palette button[data-sticky="true"] {
|
|
108
108
|
/* Keep the tinted active background; add a hairline ring so the lock state
|
|
109
109
|
is readable even when the button is also aria-pressed. */
|
|
110
|
-
box-shadow: inset 0 0 0 1px var(--accent, #d63b1f);
|
|
110
|
+
box-shadow: inset 0 0 0 1px var(--maude-hud-accent, #d63b1f);
|
|
111
111
|
}
|
|
112
112
|
.dc-tool-palette button .dc-tp-sticky-badge {
|
|
113
113
|
position: absolute;
|
|
@@ -115,7 +115,7 @@ const PALETTE_CSS = `
|
|
|
115
115
|
right: 3px;
|
|
116
116
|
width: 6px;
|
|
117
117
|
height: 6px;
|
|
118
|
-
background: var(--accent, #d63b1f);
|
|
118
|
+
background: var(--maude-hud-accent, #d63b1f);
|
|
119
119
|
border-radius: 1px;
|
|
120
120
|
box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
|
|
121
121
|
opacity: 0;
|
|
@@ -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';
|