@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.
Files changed (105) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +17 -9
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /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';