@1agh/maude 0.23.0 → 0.25.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 (127) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,104 @@
1
+ // T4.5 (DDR-054 §3 F3) — untrusted-marker for sync-written files. Verifies the
2
+ // `_untrusted/INDEX.json` marker + the managed `.claudeignore` block: written
3
+ // for the syncable set, cleared when empty, stale entries dropped on re-write,
4
+ // and user-authored `.claudeignore` content outside the block preserved.
5
+
6
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+
12
+ import type { Context } from '../context.ts';
13
+ import type { CanvasDescriptor } from '../sync/index.ts';
14
+ import { clearUntrustedMarkers, writeUntrustedMarkers } from '../sync/untrusted.ts';
15
+
16
+ let repoRoot: string;
17
+ let designRoot: string;
18
+
19
+ beforeEach(() => {
20
+ repoRoot = mkdtempSync(join(tmpdir(), 'untrusted-'));
21
+ designRoot = join(repoRoot, '.design');
22
+ mkdirSync(designRoot, { recursive: true });
23
+ });
24
+ afterEach(() => {
25
+ /* tmpdir — left for the OS to reap */
26
+ });
27
+
28
+ function ctxOf(): Context {
29
+ return { paths: { repoRoot, designRoot } } as unknown as Context;
30
+ }
31
+
32
+ function desc(slug: string, body: string): CanvasDescriptor {
33
+ return {
34
+ slug,
35
+ html: join(designRoot, body),
36
+ comments: join(designRoot, '_comments', `${slug}.json`),
37
+ annotations: join(designRoot, `${slug}.annotations.svg`),
38
+ meta: join(designRoot, body.replace(/\.(tsx|html)$/i, '.meta.json')),
39
+ css: join(designRoot, body.replace(/\.(tsx|html)$/i, '.css')),
40
+ };
41
+ }
42
+
43
+ const HUB = 'https://hub.example.com';
44
+
45
+ describe('writeUntrustedMarkers — T4.5', () => {
46
+ test('writes INDEX.json + a managed .claudeignore block for the synced set', () => {
47
+ const ctx = ctxOf();
48
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
49
+
50
+ const indexAbs = join(designRoot, '_untrusted', 'INDEX.json');
51
+ expect(existsSync(indexAbs)).toBe(true);
52
+ const index = JSON.parse(readFileSync(indexAbs, 'utf8'));
53
+ expect(index.hubUrl).toBe(HUB);
54
+ expect(index.canvases[0].slug).toBe('ui-a');
55
+ expect(index.canvases[0].body).toBe('.design/ui/a.tsx');
56
+ expect(index.canvases[0].meta).toBe('.design/ui/a.meta.json'); // Gap 2: meta is untrusted too
57
+ expect(index.canvases[0].css).toBe('.design/ui/a.css'); // Gap 3: css too
58
+ expect(index.note.toLowerCase()).toContain('untrusted');
59
+
60
+ const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
61
+ expect(ci).toContain('maude:sync-untrusted begin');
62
+ expect(ci).toContain('.design/ui/a.tsx');
63
+ expect(ci).toContain('.design/ui-a.annotations.svg');
64
+ expect(ci).toContain('.design/ui/a.meta.json');
65
+ expect(ci).toContain('.design/ui/a.css');
66
+ expect(ci).toContain('maude:sync-untrusted end');
67
+ });
68
+
69
+ test('re-write with a smaller set drops the stale entry', () => {
70
+ const ctx = ctxOf();
71
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx'), desc('ui-b', 'ui/b.tsx')], HUB);
72
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
73
+ const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
74
+ expect(ci).toContain('.design/ui/a.tsx');
75
+ expect(ci).not.toContain('.design/ui/b.tsx');
76
+ // Only one managed block (no accumulation).
77
+ expect(ci.match(/maude:sync-untrusted begin/g)?.length).toBe(1);
78
+ });
79
+
80
+ test('empty set clears INDEX.json + the .claudeignore block', () => {
81
+ const ctx = ctxOf();
82
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
83
+ writeUntrustedMarkers(ctx, [], HUB);
84
+ expect(existsSync(join(designRoot, '_untrusted', 'INDEX.json'))).toBe(false);
85
+ // .claudeignore had only our block → removed entirely.
86
+ expect(existsSync(join(repoRoot, '.claudeignore'))).toBe(false);
87
+ });
88
+
89
+ test('preserves user-authored .claudeignore content outside the block', () => {
90
+ writeFileSync(join(repoRoot, '.claudeignore'), 'secrets/\n*.key\n');
91
+ const ctx = ctxOf();
92
+ writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
93
+ let ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
94
+ expect(ci).toContain('secrets/');
95
+ expect(ci).toContain('*.key');
96
+ expect(ci).toContain('.design/ui/a.tsx');
97
+ // Clearing leaves the user content intact.
98
+ clearUntrustedMarkers(ctx);
99
+ ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
100
+ expect(ci).toContain('secrets/');
101
+ expect(ci).not.toContain('.design/ui/a.tsx');
102
+ expect(ci).not.toContain('maude:sync-untrusted');
103
+ });
104
+ });
@@ -0,0 +1,200 @@
1
+ // use-annotation-resize — FigJam resize-modifier math (pure `resizeStroke`).
2
+ //
3
+ // Covers the no-modifier baseline (must stay algebraically identical to the
4
+ // pre-modifier corner math) plus Shift (aspect-lock / 45° snap), Alt
5
+ // (scale-from-center / mirror-from-midpoint), and Shift+Alt combined, across
6
+ // every resizable stroke kind. Pure function — no DOM render needed.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import type {
11
+ ArrowStroke,
12
+ EllipseStroke,
13
+ PenStroke,
14
+ PolygonStroke,
15
+ RectStroke,
16
+ StickyStroke,
17
+ } from '../annotations-layer.tsx';
18
+ import { resizeStroke } from '../use-annotation-resize.tsx';
19
+
20
+ const rect: RectStroke = {
21
+ id: 'r1',
22
+ tool: 'rect',
23
+ color: '#000',
24
+ width: 2,
25
+ x: 0,
26
+ y: 0,
27
+ w: 100,
28
+ h: 50,
29
+ };
30
+
31
+ describe('resizeStroke / rect — no modifiers (baseline)', () => {
32
+ test('se corner anchors the nw corner (back-compat)', () => {
33
+ expect(resizeStroke(rect, 'se', 120, 80)).toEqual({ x: 0, y: 0, w: 120, h: 80 });
34
+ });
35
+
36
+ test('nw corner anchors the se corner', () => {
37
+ expect(resizeStroke(rect, 'nw', -20, -10)).toEqual({ x: -20, y: -10, w: 120, h: 60 });
38
+ });
39
+
40
+ test('dragging past the anchor flips the box (min/max parity)', () => {
41
+ // se dragged to the left of x=0 → box lives to the left of the anchor.
42
+ expect(resizeStroke(rect, 'se', -40, 80)).toEqual({ x: -40, y: 0, w: 40, h: 80 });
43
+ });
44
+ });
45
+
46
+ describe('resizeStroke / rect — Shift (aspect lock)', () => {
47
+ test('keeps the 2:1 start ratio, dominant axis drives scale', () => {
48
+ // raw 120×200 → scale = max(1.2, 4) = 4 → 400×200 (ratio preserved).
49
+ expect(resizeStroke(rect, 'se', 120, 200, { shift: true, alt: false })).toEqual({
50
+ x: 0,
51
+ y: 0,
52
+ w: 400,
53
+ h: 200,
54
+ });
55
+ });
56
+ });
57
+
58
+ describe('resizeStroke / rect — Alt (scale from center)', () => {
59
+ test('center stays fixed; both sides grow symmetrically', () => {
60
+ const out = resizeStroke(rect, 'se', 120, 80, { shift: false, alt: true });
61
+ expect(out).toEqual({ x: -20, y: -30, w: 140, h: 110 });
62
+ // center invariant: (-20 + 140/2, -30 + 110/2) === (50, 25) === start center.
63
+ expect((out as RectStroke).x + (out as RectStroke).w / 2).toBe(50);
64
+ expect((out as RectStroke).y + (out as RectStroke).h / 2).toBe(25);
65
+ });
66
+ });
67
+
68
+ describe('resizeStroke / rect — Shift+Alt (ratio + center)', () => {
69
+ test('center-anchored and ratio-locked at once', () => {
70
+ expect(resizeStroke(rect, 'se', 120, 200, { shift: true, alt: true })).toEqual({
71
+ x: -300,
72
+ y: -150,
73
+ w: 700,
74
+ h: 350,
75
+ });
76
+ });
77
+ });
78
+
79
+ describe('resizeStroke / polygon — routes through the shared bbox math', () => {
80
+ const poly: PolygonStroke = {
81
+ id: 'p1',
82
+ tool: 'polygon',
83
+ shape: 'diamond',
84
+ color: '#000',
85
+ width: 2,
86
+ x: 0,
87
+ y: 0,
88
+ w: 100,
89
+ h: 50,
90
+ };
91
+ test('Shift aspect-locks the polygon like a rect', () => {
92
+ expect(resizeStroke(poly, 'se', 120, 200, { shift: true, alt: false })).toEqual({
93
+ x: 0,
94
+ y: 0,
95
+ w: 400,
96
+ h: 200,
97
+ });
98
+ });
99
+ });
100
+
101
+ describe('resizeStroke / sticky — always 1:1 square', () => {
102
+ const sticky: StickyStroke = {
103
+ id: 's1',
104
+ tool: 'sticky',
105
+ color: '#fce8a6',
106
+ x: 0,
107
+ y: 0,
108
+ w: 100,
109
+ h: 100,
110
+ text: '',
111
+ fontSize: 16,
112
+ };
113
+ test('no modifiers — larger axis becomes the side', () => {
114
+ expect(resizeStroke(sticky, 'se', 150, 120)).toEqual({ x: 0, y: 0, w: 150, h: 150 });
115
+ });
116
+ test('Alt — square grows from the center', () => {
117
+ expect(resizeStroke(sticky, 'se', 150, 120, { shift: false, alt: true })).toEqual({
118
+ x: -50,
119
+ y: -50,
120
+ w: 200,
121
+ h: 200,
122
+ });
123
+ });
124
+ });
125
+
126
+ describe('resizeStroke / ellipse — Alt keeps the center fixed', () => {
127
+ const ell: EllipseStroke = {
128
+ id: 'e1',
129
+ tool: 'ellipse',
130
+ color: '#000',
131
+ width: 2,
132
+ cx: 50,
133
+ cy: 50,
134
+ rx: 40,
135
+ ry: 20,
136
+ };
137
+ test('center invariant under symmetric scale', () => {
138
+ expect(resizeStroke(ell, 'se', 120, 90, { shift: false, alt: true })).toEqual({
139
+ cx: 50,
140
+ cy: 50,
141
+ rx: 70,
142
+ ry: 40,
143
+ });
144
+ });
145
+ });
146
+
147
+ describe('resizeStroke / arrow', () => {
148
+ const arrow: ArrowStroke = {
149
+ id: 'a1',
150
+ tool: 'arrow',
151
+ color: '#000',
152
+ width: 2,
153
+ x1: 0,
154
+ y1: 0,
155
+ x2: 100,
156
+ y2: 0,
157
+ };
158
+ test('no modifiers — only the dragged endpoint moves', () => {
159
+ expect(resizeStroke(arrow, 'ep2', 100, 40)).toEqual({ x2: 100, y2: 40 });
160
+ });
161
+ test('Alt — the midpoint is pinned; the far end mirrors', () => {
162
+ expect(resizeStroke(arrow, 'ep2', 100, 40, { shift: false, alt: true })).toEqual({
163
+ x2: 100,
164
+ y2: 40,
165
+ x1: 0,
166
+ y1: -40,
167
+ });
168
+ });
169
+ test('Shift — shaft angle snaps to the nearest 45°', () => {
170
+ const out = resizeStroke(arrow, 'ep2', 50, 60, { shift: true, alt: false }) as ArrowStroke;
171
+ // Pointer at atan2(60,50)≈49° snaps to 45° → dx === dy along the shaft.
172
+ expect(out.x2).toBeCloseTo(out.y2 ?? Number.NaN, 6);
173
+ expect(out.x2).toBeGreaterThan(0);
174
+ });
175
+ });
176
+
177
+ describe('resizeStroke / pen — point scaling', () => {
178
+ const pen: PenStroke = {
179
+ id: 'pen1',
180
+ tool: 'pen',
181
+ color: '#000',
182
+ width: 2,
183
+ points: [
184
+ [0, 0],
185
+ [100, 0],
186
+ [100, 100],
187
+ [0, 100],
188
+ ],
189
+ };
190
+ test('no modifiers — scales around the opposite corner', () => {
191
+ const out = resizeStroke(pen, 'se', 200, 200) as PenStroke;
192
+ expect(out.points[0]).toEqual([0, 0]);
193
+ expect(out.points[2]).toEqual([200, 200]);
194
+ });
195
+ test('Alt — scales around the bbox center', () => {
196
+ const out = resizeStroke(pen, 'se', 150, 150, { shift: false, alt: true }) as PenStroke;
197
+ expect(out.points[0]).toEqual([-50, -50]);
198
+ expect(out.points[2]).toEqual([150, 150]);
199
+ });
200
+ });
@@ -12,34 +12,60 @@ import {
12
12
  } from '../use-tool-mode.tsx';
13
13
 
14
14
  describe('use-tool-mode / static', () => {
15
- test('DEFAULT_TOOLS exposes V/H/C + Phase 5.1 draw set B/R/O/A/E', () => {
15
+ test('DEFAULT_TOOLS exposes V/H/C + draw set B/R/N/A/T/E (Phase 24 single Shape tool)', () => {
16
16
  expect(DEFAULT_TOOLS.map((t) => t.id)).toEqual([
17
17
  'move',
18
18
  'hand',
19
19
  'comment',
20
20
  'pen',
21
- 'rect',
22
- 'ellipse',
21
+ 'shape',
22
+ 'sticky',
23
23
  'arrow',
24
+ 'text',
24
25
  'eraser',
25
26
  ]);
26
- expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual(['V', 'H', 'C', 'B', 'R', 'O', 'A', 'E']);
27
+ expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual([
28
+ 'V',
29
+ 'H',
30
+ 'C',
31
+ 'B',
32
+ 'R',
33
+ 'N',
34
+ 'A',
35
+ 'T',
36
+ 'E',
37
+ ]);
27
38
  });
28
39
 
29
40
  test('DEFAULT_TOOLS is immutable (Object.freeze applied)', () => {
30
41
  expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
31
42
  });
32
43
 
33
- test('default cursors per tool', () => {
44
+ test('cursors per tool (Phase 24 — ONE Kenney library for every tool + native fallback)', () => {
34
45
  const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
35
- expect(byId.move).toBe('default');
36
- expect(byId.hand).toBe('grab');
37
- expect(byId.comment).toBe('crosshair');
38
- expect(byId.pen).toBe('crosshair');
39
- expect(byId.rect).toBe('crosshair');
40
- expect(byId.ellipse).toBe('crosshair');
41
- expect(byId.arrow).toBe('crosshair');
42
- expect(byId.eraser).toBe('cell');
46
+ // Phase 24 — EVERY tool (incl. move) ships a Kenney data-URI SVG cursor that
47
+ // falls back to the right native cursor if the image is rejected.
48
+ for (const id of [
49
+ 'move',
50
+ 'hand',
51
+ 'comment',
52
+ 'pen',
53
+ 'shape',
54
+ 'sticky',
55
+ 'arrow',
56
+ 'text',
57
+ 'eraser',
58
+ ]) {
59
+ expect(byId[id]).toContain('url("data:image/svg+xml,');
60
+ expect(byId[id]).toContain('32'); // 32×32 cursor
61
+ }
62
+ // Native fallbacks are preserved after the custom URL.
63
+ expect(byId.move).toMatch(/, default$/);
64
+ expect(byId.hand).toMatch(/, grab$/);
65
+ expect(byId.text).toMatch(/, text$/);
66
+ expect(byId.eraser).toMatch(/, cell$/);
67
+ expect(byId.pen).toMatch(/, crosshair$/);
68
+ expect(byId.shape).toMatch(/, crosshair$/);
43
69
  });
44
70
  });
45
71
 
@@ -14,10 +14,15 @@
14
14
 
15
15
  import { useEffect, useRef, useState } from 'react';
16
16
 
17
- import { IconChevronDown, IconPresentation, TOOL_ICONS } from './canvas-icons.tsx';
17
+ import {
18
+ IconChevronDown,
19
+ IconPresentation,
20
+ SHAPE_KIND_ICONS,
21
+ TOOL_ICONS,
22
+ } from './canvas-icons.tsx';
18
23
  import { useViewportControllerContext } from './canvas-lib.tsx';
19
24
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
20
- import { useToolMode } from './use-tool-mode.tsx';
25
+ import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
21
26
 
22
27
  // ─────────────────────────────────────────────────────────────────────────────
23
28
  // Styles — pulled from menubar tokens (`.mb` family). Centered bottom toolbar,
@@ -35,13 +40,13 @@ const PALETTE_CSS = `
35
40
  transform: translateX(-50%);
36
41
  display: flex;
37
42
  align-items: stretch;
38
- background: var(--u-bg-0, var(--bg-0, #ffffff));
39
- border: 1px solid var(--u-fg-0, #1c1917);
43
+ background: var(--maude-chrome-bg-0, #ffffff);
44
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
40
45
  border-radius: 8px;
41
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
42
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
46
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
47
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
43
48
  font-size: 12px;
44
- color: var(--u-fg-0, var(--fg-0, #1a1a1a));
49
+ color: var(--maude-chrome-fg-0, #1a1a1a);
45
50
  z-index: 6;
46
51
  user-select: none;
47
52
  /* Intentionally NO overflow:hidden — the zoom popover (.dc-tp-popover) is
@@ -56,7 +61,7 @@ const PALETTE_CSS = `
56
61
  }
57
62
  .dc-tool-palette .dc-tp-sep {
58
63
  width: 1px;
59
- background: var(--u-border-subtle, rgba(0,0,0,0.08));
64
+ background: var(--maude-chrome-border, rgba(0,0,0,0.08));
60
65
  margin: 6px 0;
61
66
  }
62
67
  .dc-tool-palette button {
@@ -70,14 +75,14 @@ const PALETTE_CSS = `
70
75
  display: inline-flex;
71
76
  align-items: center;
72
77
  justify-content: center;
73
- color: var(--fg-1, rgba(40,30,20,0.75));
78
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.75));
74
79
  cursor: pointer;
75
80
  position: relative;
76
81
  transition: background-color 80ms linear, color 80ms linear;
77
82
  }
78
83
  .dc-tool-palette button:hover {
79
84
  background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
80
- color: var(--fg-0, #1a1a1a);
85
+ color: var(--maude-chrome-fg-0, #1a1a1a);
81
86
  }
82
87
  .dc-tool-palette button:focus-visible {
83
88
  outline: 2px solid var(--maude-hud-accent, #d63b1f);
@@ -117,7 +122,7 @@ const PALETTE_CSS = `
117
122
  height: 6px;
118
123
  background: var(--maude-hud-accent, #d63b1f);
119
124
  border-radius: 1px;
120
- box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
125
+ box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
121
126
  opacity: 0;
122
127
  transition: opacity 50ms linear 50ms;
123
128
  pointer-events: none;
@@ -132,7 +137,7 @@ const PALETTE_CSS = `
132
137
  min-width: 56px;
133
138
  padding: 0 8px;
134
139
  font-variant-numeric: tabular-nums;
135
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
140
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
136
141
  font-size: 11px;
137
142
  letter-spacing: 0.04em;
138
143
  border-radius: 6px;
@@ -145,16 +150,16 @@ const PALETTE_CSS = `
145
150
  position: absolute;
146
151
  right: 4px;
147
152
  bottom: 44px;
148
- background: var(--u-bg-0, var(--bg-0, #ffffff));
149
- border: 1px solid var(--u-fg-0, #1c1917);
153
+ background: var(--maude-chrome-bg-0, #ffffff);
154
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
150
155
  border-radius: 8px;
151
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
156
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
152
157
  display: flex;
153
158
  flex-direction: column;
154
159
  padding: 4px;
155
160
  min-width: 160px;
156
161
  z-index: 7;
157
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
162
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
158
163
  }
159
164
  .dc-tp-popover button {
160
165
  appearance: none;
@@ -171,12 +176,45 @@ const PALETTE_CSS = `
171
176
  gap: 16px;
172
177
  width: 100%;
173
178
  }
174
- .dc-tp-popover button:hover { background: rgba(0,0,0,0.04); }
179
+ .dc-tp-popover button:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 5%, transparent); }
175
180
  .dc-tp-popover .dc-tp-kbd {
176
181
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
177
182
  font-size: 11px;
178
183
  opacity: 0.55;
179
184
  }
185
+ /* Phase 24 — Shape tool button + its kind popover. The wrapper is the popover's
186
+ positioning context so it anchors directly above the button. */
187
+ .dc-tool-palette .dc-tp-shape {
188
+ position: relative;
189
+ display: inline-flex;
190
+ }
191
+ .dc-tool-palette .dc-tp-shape > button { position: relative; }
192
+ .dc-tool-palette .dc-tp-shape-caret {
193
+ position: absolute;
194
+ right: 2px;
195
+ bottom: 1px;
196
+ display: inline-flex;
197
+ opacity: 0.5;
198
+ pointer-events: none;
199
+ }
200
+ .dc-tp-shape-popover {
201
+ position: absolute;
202
+ bottom: calc(100% + 8px);
203
+ left: 50%;
204
+ transform: translateX(-50%);
205
+ display: flex;
206
+ gap: 2px;
207
+ padding: 4px;
208
+ background: var(--maude-chrome-bg-0, #ffffff);
209
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
210
+ border-radius: 8px;
211
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
212
+ z-index: 8;
213
+ }
214
+ .dc-tp-shape-popover button[aria-checked="true"] {
215
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
216
+ color: var(--maude-hud-accent, #d63b1f);
217
+ }
180
218
  `.trim();
181
219
 
182
220
  function ensurePaletteStyles(): void {
@@ -189,29 +227,59 @@ function ensurePaletteStyles(): void {
189
227
  }
190
228
 
191
229
  const NAV_TOOLS = ['move', 'hand', 'comment'] as const;
192
- const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'arrow', 'eraser'] as const;
230
+ // Phase 24 the two rect/ellipse buttons collapse into one Shape tool (with a
231
+ // kind popover); sticky/arrow/text/eraser keep their order.
232
+ const DRAW_TOOLS = ['pen', 'shape', 'sticky', 'arrow', 'text', 'eraser'] as const;
233
+
234
+ // Phase 24 — the Shape tool's primitive picker (popover order matches FigJam).
235
+ const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
236
+ { kind: 'square', label: 'Square' },
237
+ { kind: 'rounded', label: 'Rounded square' },
238
+ { kind: 'circle', label: 'Circle' },
239
+ { kind: 'diamond', label: 'Diamond' },
240
+ { kind: 'triangle', label: 'Triangle' },
241
+ { kind: 'triangle-down', label: 'Triangle down' },
242
+ ];
193
243
 
194
244
  // ─────────────────────────────────────────────────────────────────────────────
195
245
  // Component
196
246
 
197
247
  export function ToolPalette() {
198
248
  ensurePaletteStyles();
199
- const { tool, setTool, tools, sticky, toggleSticky } = useToolMode();
249
+ const { tool, setTool, tools, sticky, toggleSticky, shapeKind, setShapeKind } = useToolMode();
200
250
  const controller = useViewportControllerContext();
201
251
  const visibilityCtx = useAnnotationsVisibility();
202
252
  const [mounted, setMounted] = useState(false);
203
253
  const [zoomOpen, setZoomOpen] = useState(false);
254
+ const [shapeOpen, setShapeOpen] = useState(false);
204
255
  const containerRef = useRef<HTMLDivElement | null>(null);
205
256
 
206
257
  useEffect(() => setMounted(true), []);
207
258
  useEffect(() => {
208
- if (!zoomOpen) return;
259
+ if (!zoomOpen && !shapeOpen) return;
209
260
  const onDown = (e: PointerEvent) => {
210
- if (!containerRef.current?.contains(e.target as Node)) setZoomOpen(false);
261
+ if (!containerRef.current?.contains(e.target as Node)) {
262
+ setZoomOpen(false);
263
+ setShapeOpen(false);
264
+ }
265
+ };
266
+ const onKey = (e: KeyboardEvent) => {
267
+ if (e.key === 'Escape') {
268
+ setZoomOpen(false);
269
+ setShapeOpen(false);
270
+ }
211
271
  };
212
272
  document.addEventListener('pointerdown', onDown, true);
213
- return () => document.removeEventListener('pointerdown', onDown, true);
214
- }, [zoomOpen]);
273
+ document.addEventListener('keydown', onKey, true);
274
+ return () => {
275
+ document.removeEventListener('pointerdown', onDown, true);
276
+ document.removeEventListener('keydown', onKey, true);
277
+ };
278
+ }, [zoomOpen, shapeOpen]);
279
+ // Close the shape popover whenever the tool changes away from Shape.
280
+ useEffect(() => {
281
+ if (tool !== 'shape') setShapeOpen(false);
282
+ }, [tool]);
215
283
 
216
284
  if (!mounted) return null;
217
285
 
@@ -246,6 +314,63 @@ export function ToolPalette() {
246
314
  );
247
315
  };
248
316
 
317
+ // Phase 24 — the single Shape tool. The button shows the CURRENT shape-kind
318
+ // glyph (so the toolbar reflects what you'll draw); first click arms the tool,
319
+ // clicking it again (or the caret) opens the 6-kind popover.
320
+ const renderShapeButton = () => {
321
+ const isShape = tool === 'shape';
322
+ const isSticky = sticky.locked && sticky.tool === 'shape';
323
+ const KindIcon = SHAPE_KIND_ICONS[shapeKind] ?? TOOL_ICONS.shape;
324
+ return (
325
+ <span key="shape" className="dc-tp-shape">
326
+ <button
327
+ type="button"
328
+ aria-label={`Shape (R) — ${shapeKind}, click again for shape types`}
329
+ aria-pressed={isShape}
330
+ aria-haspopup="menu"
331
+ aria-expanded={shapeOpen}
332
+ data-sticky={isSticky ? 'true' : undefined}
333
+ title="Shape (R) · double-click to lock"
334
+ onClick={() => {
335
+ if (!isShape) setTool('shape');
336
+ else setShapeOpen((o) => !o);
337
+ }}
338
+ onDoubleClick={() => toggleSticky('shape')}
339
+ >
340
+ {KindIcon ? <KindIcon /> : null}
341
+ <span className="dc-tp-shape-caret" aria-hidden="true">
342
+ <IconChevronDown size={9} />
343
+ </span>
344
+ {isSticky ? <span className="dc-tp-sticky-badge" aria-hidden="true" /> : null}
345
+ </button>
346
+ {shapeOpen ? (
347
+ <div className="dc-tp-shape-popover" role="menu" aria-label="Shape type">
348
+ {SHAPE_KINDS.map(({ kind, label }) => {
349
+ const Icon = SHAPE_KIND_ICONS[kind];
350
+ return (
351
+ <button
352
+ key={kind}
353
+ type="button"
354
+ role="menuitemradio"
355
+ aria-checked={shapeKind === kind}
356
+ aria-label={label}
357
+ title={label}
358
+ onClick={() => {
359
+ setShapeKind(kind);
360
+ setTool('shape');
361
+ setShapeOpen(false);
362
+ }}
363
+ >
364
+ {Icon ? <Icon /> : null}
365
+ </button>
366
+ );
367
+ })}
368
+ </div>
369
+ ) : null}
370
+ </span>
371
+ );
372
+ };
373
+
249
374
  return (
250
375
  <div ref={containerRef} className="dc-tool-palette" role="toolbar" aria-label="Canvas tools">
251
376
  <div className="dc-tp-group">
@@ -253,7 +378,13 @@ export function ToolPalette() {
253
378
  </div>
254
379
  <div className="dc-tp-sep" />
255
380
  <div className="dc-tp-group">
256
- {drawList.map((t) => (t ? renderToolButton(t.id, t.label, t.shortcut) : null))}
381
+ {drawList.map((t) =>
382
+ t
383
+ ? t.id === 'shape'
384
+ ? renderShapeButton()
385
+ : renderToolButton(t.id, t.label, t.shortcut)
386
+ : null
387
+ )}
257
388
  </div>
258
389
  <div className="dc-tp-sep" />
259
390
  <div className="dc-tp-group">
@@ -23,11 +23,11 @@ const HUD_CSS = `
23
23
  z-index: 7;
24
24
  pointer-events: none;
25
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));
26
+ background: var(--maude-chrome-bg-2, rgba(20, 20, 20, 0.85));
27
+ color: var(--maude-chrome-fg-1, rgba(255, 255, 255, 0.85));
28
+ border: 1px solid var(--maude-chrome-border, rgba(255, 255, 255, 0.08));
29
29
  border-radius: 6px;
30
- font: 11px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
30
+ font: 11px var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
31
31
  letter-spacing: 0.02em;
32
32
  user-select: none;
33
33
  opacity: 0;