@1agh/maude 0.24.0 → 0.26.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 (84) hide show
  1. package/cli/commands/design.mjs +5 -0
  2. package/cli/lib/design-link.mjs +13 -6
  3. package/cli/lib/gitignore-block.mjs +1 -0
  4. package/cli/lib/gitignore-block.test.mjs +1 -0
  5. package/package.json +8 -8
  6. package/plugins/design/dependencies.json +30 -2
  7. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  8. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  9. package/plugins/design/dev-server/api.ts +15 -1
  10. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  12. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  13. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  14. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  15. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  16. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  17. package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
  18. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  19. package/plugins/design/dev-server/bin/draw-build.sh +48 -0
  20. package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
  21. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  22. package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
  23. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  24. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  25. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  26. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  27. package/plugins/design/dev-server/canvas-lib.tsx +110 -0
  28. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  29. package/plugins/design/dev-server/client/app.jsx +1084 -417
  30. package/plugins/design/dev-server/config.schema.json +10 -0
  31. package/plugins/design/dev-server/context.ts +9 -0
  32. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  33. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  34. package/plugins/design/dev-server/draw/brush.ts +639 -0
  35. package/plugins/design/dev-server/draw/composition.ts +229 -0
  36. package/plugins/design/dev-server/draw/geometry.ts +578 -0
  37. package/plugins/design/dev-server/draw/index.ts +28 -0
  38. package/plugins/design/dev-server/draw/layout.ts +260 -0
  39. package/plugins/design/dev-server/draw/optimize.ts +65 -0
  40. package/plugins/design/dev-server/draw/palette.ts +417 -0
  41. package/plugins/design/dev-server/draw/primitives.ts +643 -0
  42. package/plugins/design/dev-server/draw/serialize.ts +458 -0
  43. package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
  44. package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
  45. package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
  46. package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
  47. package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
  48. package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
  49. package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
  50. package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
  51. package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
  52. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  53. package/plugins/design/dev-server/exporters/html.ts +4 -1
  54. package/plugins/design/dev-server/exporters/index.ts +4 -1
  55. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  56. package/plugins/design/dev-server/exporters/png.ts +21 -2
  57. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  58. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  59. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  60. package/plugins/design/dev-server/http.ts +40 -9
  61. package/plugins/design/dev-server/input-router.tsx +9 -2
  62. package/plugins/design/dev-server/sync/index.ts +73 -17
  63. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  64. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  65. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  66. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  67. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  68. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  69. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  70. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  71. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  72. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  73. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  74. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  75. package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
  76. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  77. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  78. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  79. package/plugins/design/dev-server/tsconfig.json +8 -1
  80. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  81. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  82. package/plugins/design/templates/_shell.html +36 -9
  83. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
  84. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
@@ -0,0 +1,49 @@
1
+ // Unit cover for the shared Chromium launcher's error mapping. The launch path
2
+ // itself is integration-shape (needs a real browser); here we pin the pure
3
+ // classifier + the user-facing contract so a regression can't silently turn the
4
+ // "browser not installed" 500 back into an opaque stack trace.
5
+ //
6
+ // Regression guard for `.ai/logs/rca/issue-nefunguji-exporty.md`.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { INSTALL_HINT, NO_BROWSER_EXIT, isMissingBrowserError } from '../../bin/_pw-launch.mjs';
11
+
12
+ describe('_pw-launch — missing-browser classifier', () => {
13
+ test('matches the real Playwright "Executable doesn\'t exist" message', () => {
14
+ const real =
15
+ "browserType.launch: Executable doesn't exist at " +
16
+ '/Users/x/Library/Caches/ms-playwright/chromium_headless_shell-1223/' +
17
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell\n' +
18
+ '╔════════════════════════════════════════════════════════════╗';
19
+ expect(isMissingBrowserError(real)).toBe(true);
20
+ });
21
+
22
+ test('matches the "please run … to download" + "playwright install" variants', () => {
23
+ expect(isMissingBrowserError('Please run the following command to download new browsers')).toBe(
24
+ true
25
+ );
26
+ expect(
27
+ isMissingBrowserError('Looks like Playwright was just installed... npx playwright install')
28
+ ).toBe(true);
29
+ });
30
+
31
+ test('does NOT match unrelated launch failures (preserve real diagnostics)', () => {
32
+ expect(isMissingBrowserError('Target page, context or browser has been closed')).toBe(false);
33
+ expect(isMissingBrowserError('connect ECONNREFUSED 127.0.0.1:9222')).toBe(false);
34
+ expect(isMissingBrowserError('Timeout 30000ms exceeded')).toBe(false);
35
+ expect(isMissingBrowserError('')).toBe(false);
36
+ expect(isMissingBrowserError(null)).toBe(false);
37
+ expect(isMissingBrowserError(undefined)).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('_pw-launch — user-facing contract', () => {
42
+ test('INSTALL_HINT is the copy-pasteable remediation', () => {
43
+ expect(INSTALL_HINT).toContain('npx playwright install chromium');
44
+ });
45
+
46
+ test('NO_BROWSER_EXIT is a distinct non-zero code', () => {
47
+ expect(NO_BROWSER_EXIT).toBeGreaterThan(0);
48
+ });
49
+ });
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><g data-id="st1" data-tool="sticky" data-r="8" data-fs="14" fill="#ffe27a"><rect x="40" y="50" width="200" height="160" rx="8" ry="8"/><text data-sticky-body="1" x="52" y="62" font-size="14" fill="#1a1a1a" dominant-baseline="hanging">ship it</text></g><rect data-id="r1" data-tool="rect" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="10" y="20" width="120" height="80" rx="8" ry="8" data-r="8"/><g data-id="a1" data-tool="arrow" stroke="#e5484d" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" data-start-head="triangle" data-dash="1"><line x1="0" y1="0" x2="80" y2="40" stroke-dasharray="6 4"/><polyline points="17.99801569252915,0.2672660312767773 0,0 11.012622240538908,14.238052935257258" fill="#e5484d"/><polyline points="62.00198430747085,39.732733968723224 80,40 68.98737775946108,25.761947064742742" fill="#e5484d"/></g></svg>
@@ -201,10 +201,10 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
201
201
  });
202
202
  });
203
203
 
204
- test('R → tool rect', () => {
204
+ test('R → tool shape (Phase 24 — single Shape tool)', () => {
205
205
  expect(classify(base({ type: 'keydown', key: 'r' }))).toEqual({
206
206
  kind: 'tool',
207
- tool: 'rect',
207
+ tool: 'shape',
208
208
  });
209
209
  });
210
210
 
@@ -222,13 +222,20 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
222
222
  });
223
223
  });
224
224
 
225
- test('O → tool ellipse (Phase 5.1)', () => {
225
+ test('O → tool shape (Phase 24 — legacy ellipse key now arms Shape)', () => {
226
226
  expect(classify(base({ type: 'keydown', key: 'o' }))).toEqual({
227
227
  kind: 'tool',
228
- tool: 'ellipse',
228
+ tool: 'shape',
229
229
  });
230
230
  });
231
231
 
232
+ test('shape tool owns bare pointer events (SVG overlay claims)', () => {
233
+ expect(classify(base({ type: 'pointerdown', activeTool: 'shape', button: 0 })).kind).toBe(
234
+ 'no-op'
235
+ );
236
+ expect(classify(base({ type: 'pointermove', activeTool: 'shape' })).kind).toBe('no-op');
237
+ });
238
+
232
239
  test('N → tool sticky (Phase 21)', () => {
233
240
  expect(classify(base({ type: 'keydown', key: 'n' }))).toEqual({
234
241
  kind: 'tool',
@@ -97,4 +97,36 @@ describe('sanitizeAnnotationSvg — A3', () => {
97
97
  expect(out.toLowerCase()).not.toContain('<set');
98
98
  expect(out.toLowerCase()).not.toContain('<animate');
99
99
  });
100
+
101
+ // Phase 24 (DDR-067 security review) — a handler glued onto the previous
102
+ // attribute's closing quote with NO separating whitespace is a distinct
103
+ // attribute to HTML/SVG parsers; the old `\s`-anchored denylist missed it.
104
+ test('strips a quote-glued on*= handler (no separating whitespace)', () => {
105
+ const out = sanitizeAnnotationSvg(
106
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
107
+ '<circle cx="1" cy="1" r="2"onload="alert(document.cookie)"/>' +
108
+ '<rect x="0" y="0" width="9" height="9"onclick=\'evil()\'/></svg>'
109
+ );
110
+ // (a bare /on\w+=/ would false-match `data-mdcc-annotations="1"` — target
111
+ // the actual handler names instead)
112
+ expect(out).not.toMatch(/\bon(?:load|click|error|mouse\w+|focus)\s*=/i);
113
+ expect(out).not.toContain('alert(document.cookie)');
114
+ expect(out).not.toContain('evil()');
115
+ // The legit element + its real attributes survive intact.
116
+ expect(out).toContain('<circle');
117
+ expect(out).toContain('r="2"');
118
+ expect(out).toContain('<rect');
119
+ expect(out).toContain('width="9"');
120
+ });
121
+
122
+ // Phase 24 — the new shape/arrowhead vocabulary survives byte-intact.
123
+ test('keeps Phase 24 <polygon>/<circle> + data-* attrs byte-intact', () => {
124
+ const phase24 =
125
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
126
+ '<polygon data-id="pg" data-tool="polygon" stroke="#222" stroke-width="2" fill="none" data-shape="diamond" points="20,0 40,20 20,40 0,20"/>' +
127
+ '<g data-id="a" data-tool="arrow" stroke="#111" stroke-width="2" fill="none" data-start-head="circle" data-line-type="curved"><path d="M0 0 Q10 10 20 0"/><circle cx="20" cy="0" r="6" fill="#111"/></g>' +
128
+ '<g data-id="st" data-tool="sticky" data-r="8" data-fs="16" fill="#fce8a6" data-bold="1" data-align="center"><rect x="0" y="0" width="200" height="200" rx="8" ry="8"/><text data-sticky-body="1" x="12" y="12" font-size="16" fill="#1a1a1a" dominant-baseline="hanging">x</text></g>' +
129
+ '</svg>';
130
+ expect(sanitizeAnnotationSvg(phase24)).toBe(phase24);
131
+ });
100
132
  });
@@ -783,6 +783,58 @@ describe('scanCanvases', () => {
783
783
  });
784
784
  });
785
785
 
786
+ // DDR-072 — project-level TSX opt-in (linkedHub.syncTsx). A .tsx with no
787
+ // explicit sidecar verdict defaults to syncable when the project flag is on;
788
+ // the per-canvas sidecar still wins; the sandbox coupling is preserved.
789
+ describe('scanCanvases — project-level syncTsx (DDR-072)', () => {
790
+ test('syncTsx:true + sandbox ON admits a .tsx WITHOUT a per-canvas opt-in', async () => {
791
+ const ctx = makeCtx(
792
+ { url: 'https://h.example.com', linkedAt: 1, syncTsx: true },
793
+ 'http://localhost:9'
794
+ );
795
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'free.tsx'), 'export default () => null;');
796
+ const scan = await scanCanvases(ctx);
797
+ const free = scan.canvases.find((c) => c.slug === 'ui-free');
798
+ expect(free).toBeDefined();
799
+ expect(free?.html).toBe(join(ctx.paths.designRoot, 'ui', 'free.tsx'));
800
+ expect(scan.tsxCount).toBe(0);
801
+ });
802
+
803
+ test('syncTsx:true is INERT when the sandbox split is OFF (Lock-2 coupling preserved)', async () => {
804
+ // canvasOrigin undefined → sandbox not in force → no .tsx syncs, flag or not.
805
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1, syncTsx: true });
806
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'free.tsx'), 'export default () => null;');
807
+ const scan = await scanCanvases(ctx);
808
+ expect(scan.canvases.map((c) => c.slug)).not.toContain('ui-free');
809
+ expect(scan.tsxCount).toBe(1);
810
+ });
811
+
812
+ test('per-canvas "syncable": false OVERRIDES syncTsx:true (sidecar wins)', async () => {
813
+ const ctx = makeCtx(
814
+ { url: 'https://h.example.com', linkedAt: 1, syncTsx: true },
815
+ 'http://localhost:9'
816
+ );
817
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'secret.tsx'), 'export default () => null;');
818
+ writeFileSync(
819
+ join(ctx.paths.designRoot, 'ui', 'secret.meta.json'),
820
+ JSON.stringify({ syncable: false, title: 'Secret' })
821
+ );
822
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'open.tsx'), 'export default () => null;');
823
+ const scan = await scanCanvases(ctx);
824
+ const slugs = scan.canvases.map((c) => c.slug);
825
+ expect(slugs).toContain('ui-open'); // no sidecar → project default
826
+ expect(slugs).not.toContain('ui-secret'); // explicit opt-out wins
827
+ });
828
+
829
+ test('without the flag (syncTsx absent) a .tsx still needs the per-canvas opt-in', async () => {
830
+ const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 }, 'http://localhost:9');
831
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'plain.tsx'), 'export default () => null;');
832
+ const scan = await scanCanvases(ctx);
833
+ expect(scan.canvases.map((c) => c.slug)).not.toContain('ui-plain');
834
+ expect(scan.tsxCount).toBe(1);
835
+ });
836
+ });
837
+
786
838
  describe('buildNoSyncablePayload', () => {
787
839
  test('TSX-only project: reason names the count + DDR-060', () => {
788
840
  const p = buildNoSyncablePayload('https://h.example.com', 3, '/proj/.design');
@@ -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,14 +12,13 @@ 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 + draw set B/R/O/N/A/T/E (Phase 21 sticky+text)', () => {
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',
23
22
  'sticky',
24
23
  'arrow',
25
24
  'text',
@@ -31,7 +30,6 @@ describe('use-tool-mode / static', () => {
31
30
  'C',
32
31
  'B',
33
32
  'R',
34
- 'O',
35
33
  'N',
36
34
  'A',
37
35
  'T',
@@ -43,17 +41,16 @@ describe('use-tool-mode / static', () => {
43
41
  expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
44
42
  });
45
43
 
46
- test('default cursors per tool (Phase 21custom SVG cursors + native fallback)', () => {
44
+ test('cursors per tool (Phase 24ONE Kenney library for every tool + native fallback)', () => {
47
45
  const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
48
- // Move keeps the system arrow; every other tool ships a data-URI SVG cursor
49
- // that falls back to the right native cursor if the image is rejected.
50
- expect(byId.move).toBe('default');
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.
51
48
  for (const id of [
49
+ 'move',
52
50
  'hand',
53
51
  'comment',
54
52
  'pen',
55
- 'rect',
56
- 'ellipse',
53
+ 'shape',
57
54
  'sticky',
58
55
  'arrow',
59
56
  'text',
@@ -63,11 +60,12 @@ describe('use-tool-mode / static', () => {
63
60
  expect(byId[id]).toContain('32'); // 32×32 cursor
64
61
  }
65
62
  // Native fallbacks are preserved after the custom URL.
63
+ expect(byId.move).toMatch(/, default$/);
66
64
  expect(byId.hand).toMatch(/, grab$/);
67
65
  expect(byId.text).toMatch(/, text$/);
68
66
  expect(byId.eraser).toMatch(/, cell$/);
69
67
  expect(byId.pen).toMatch(/, crosshair$/);
70
- expect(byId.rect).toMatch(/, crosshair$/);
68
+ expect(byId.shape).toMatch(/, crosshair$/);
71
69
  });
72
70
  });
73
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,
@@ -177,6 +182,39 @@ const PALETTE_CSS = `
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,31 +227,59 @@ function ensurePaletteStyles(): void {
189
227
  }
190
228
 
191
229
  const NAV_TOOLS = ['move', 'hand', 'comment'] as const;
192
- // Phase 21sticky clusters with the paper primitives (after ellipse); text
193
- // sits at the constructive end before the destructive eraser, which stays last.
194
- const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'sticky', 'arrow', 'text', 'eraser'] as const;
230
+ // Phase 24the 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
+ ];
195
243
 
196
244
  // ─────────────────────────────────────────────────────────────────────────────
197
245
  // Component
198
246
 
199
247
  export function ToolPalette() {
200
248
  ensurePaletteStyles();
201
- const { tool, setTool, tools, sticky, toggleSticky } = useToolMode();
249
+ const { tool, setTool, tools, sticky, toggleSticky, shapeKind, setShapeKind } = useToolMode();
202
250
  const controller = useViewportControllerContext();
203
251
  const visibilityCtx = useAnnotationsVisibility();
204
252
  const [mounted, setMounted] = useState(false);
205
253
  const [zoomOpen, setZoomOpen] = useState(false);
254
+ const [shapeOpen, setShapeOpen] = useState(false);
206
255
  const containerRef = useRef<HTMLDivElement | null>(null);
207
256
 
208
257
  useEffect(() => setMounted(true), []);
209
258
  useEffect(() => {
210
- if (!zoomOpen) return;
259
+ if (!zoomOpen && !shapeOpen) return;
211
260
  const onDown = (e: PointerEvent) => {
212
- 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
+ }
213
271
  };
214
272
  document.addEventListener('pointerdown', onDown, true);
215
- return () => document.removeEventListener('pointerdown', onDown, true);
216
- }, [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]);
217
283
 
218
284
  if (!mounted) return null;
219
285
 
@@ -248,6 +314,63 @@ export function ToolPalette() {
248
314
  );
249
315
  };
250
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
+
251
374
  return (
252
375
  <div ref={containerRef} className="dc-tool-palette" role="toolbar" aria-label="Canvas tools">
253
376
  <div className="dc-tp-group">
@@ -255,7 +378,13 @@ export function ToolPalette() {
255
378
  </div>
256
379
  <div className="dc-tp-sep" />
257
380
  <div className="dc-tp-group">
258
- {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
+ )}
259
388
  </div>
260
389
  <div className="dc-tp-sep" />
261
390
  <div className="dc-tp-group">
@@ -21,6 +21,13 @@
21
21
  "forceConsistentCasingInFileNames": true,
22
22
  "allowImportingTsExtensions": true
23
23
  },
24
- "include": ["*.ts", "client/**/*.ts", "client/**/*.tsx", "client/**/*.jsx", "test/**/*.ts"],
24
+ "include": [
25
+ "*.ts",
26
+ "draw/**/*.ts",
27
+ "client/**/*.ts",
28
+ "client/**/*.tsx",
29
+ "client/**/*.jsx",
30
+ "test/**/*.ts"
31
+ ],
25
32
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
26
33
  }