@1agh/maude 0.24.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 (50) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dependencies.json +30 -2
  3. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  4. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  5. package/plugins/design/dev-server/api.ts +15 -1
  6. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  7. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  8. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  9. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  10. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  12. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  13. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  14. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  15. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  16. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  17. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  18. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  19. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  20. package/plugins/design/dev-server/client/app.jsx +1084 -417
  21. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  22. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  23. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  24. package/plugins/design/dev-server/exporters/html.ts +4 -1
  25. package/plugins/design/dev-server/exporters/index.ts +4 -1
  26. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  27. package/plugins/design/dev-server/exporters/png.ts +21 -2
  28. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  29. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  30. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  31. package/plugins/design/dev-server/http.ts +40 -9
  32. package/plugins/design/dev-server/input-router.tsx +9 -2
  33. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  34. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  35. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  36. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  37. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  39. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  40. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  41. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  42. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  43. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  44. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  45. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  46. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  47. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  48. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  49. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  50. package/plugins/design/templates/_shell.html +36 -9
@@ -2,8 +2,10 @@
2
2
  * @file use-annotation-resize.tsx — Task 23 (Wave 2, G4)
3
3
  * @scope plugins/design/dev-server/use-annotation-resize.tsx
4
4
  * @purpose Screen-space corner / endpoint handles for the selected
5
- * annotation. Per-tool resize math; modifier semantics (Shift
6
- * aspect-lock, Alt scale-from-center) are deferred to a follow-up.
5
+ * annotation. Per-tool resize math with FigJam resize modifiers:
6
+ * Shift = lock aspect ratio (45° angle snap for arrows), Alt =
7
+ * scale from center, Shift+Alt = both. Modifiers update live on
8
+ * keydown/keyup mid-drag (re-applied at the last pointer position).
7
9
  *
8
10
  * Handles are `position: fixed` DOM siblings of the canvas (the
9
11
  * same pattern element selection uses) — they stay 8 × 8 CSS px
@@ -21,6 +23,7 @@ import {
21
23
  type ArrowStroke,
22
24
  type EllipseStroke,
23
25
  type PenStroke,
26
+ type PolygonStroke,
24
27
  type RectStroke,
25
28
  type StickyStroke,
26
29
  type Stroke,
@@ -43,9 +46,16 @@ const RESIZE_CSS = `
43
46
  pointer-events: auto;
44
47
  touch-action: none;
45
48
  }
46
- .dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize; }
47
- .dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize; }
48
- .dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move; }
49
+ /* Phase 24 '!important' so the scale/move affordance beats use-tool-mode's
50
+ blanket '* { cursor: <tool> !important }' (move mode). Without it the move
51
+ glyph clobbered the resize cursors and the user saw no scale affordance over
52
+ a handle. Specificity already wins ('.class[attr]' > '*'); the '!important'
53
+ is what lets it through against the other '!important' rule. See DDR-067.
54
+ NOTE: keep this comment backtick-free — it lives inside the RESIZE_CSS
55
+ template literal and a stray backtick closes it (bun parse fail, §6). */
56
+ .dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize !important; }
57
+ .dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; }
58
+ .dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move !important; }
49
59
  `.trim();
50
60
 
51
61
  function ensureResizeStyles(): void {
@@ -62,86 +72,200 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se' | 'ep1' | 'ep2';
62
72
  /** Stroke types that expose resize handles. Text inherits its anchor bbox. */
63
73
  function isResizable(
64
74
  s: Stroke
65
- ): s is RectStroke | EllipseStroke | ArrowStroke | PenStroke | StickyStroke {
75
+ ): s is RectStroke | EllipseStroke | PolygonStroke | ArrowStroke | PenStroke | StickyStroke {
66
76
  return (
67
77
  s.tool === 'rect' ||
68
78
  s.tool === 'ellipse' ||
79
+ s.tool === 'polygon' ||
69
80
  s.tool === 'arrow' ||
70
81
  s.tool === 'pen' ||
71
82
  s.tool === 'sticky'
72
83
  );
73
84
  }
74
85
 
86
+ /**
87
+ * Resize modifiers (FigJam-parity). Held during a handle drag:
88
+ * • `shift` — lock to the start aspect ratio (45° angle snap for arrows;
89
+ * always-1:1 stickies ignore it — they're square regardless).
90
+ * • `alt` — scale symmetrically around the stroke's center / midpoint.
91
+ * Both together combine (ratio-locked AND center-anchored). With neither held
92
+ * the result is byte-identical to the pre-modifier behaviour.
93
+ */
94
+ export interface ResizeMods {
95
+ shift: boolean;
96
+ alt: boolean;
97
+ }
98
+
99
+ const NO_MODS: ResizeMods = { shift: false, alt: false };
100
+
101
+ const isWestCorner = (c: Corner): boolean => c === 'nw' || c === 'sw';
102
+ const isNorthCorner = (c: Corner): boolean => c === 'nw' || c === 'ne';
103
+
104
+ /**
105
+ * Shared bbox resize for rect / polygon / sticky / ellipse. Returns the new
106
+ * axis-aligned box for the dragged `corner` moving to world (wx, wy):
107
+ * • normal — the diagonally-opposite corner is the fixed anchor.
108
+ * • Alt — the box's center is the fixed anchor (symmetric scale).
109
+ * • Shift — keep the start aspect ratio (the dominant axis drives scale).
110
+ * • square — force 1:1 regardless of Shift (sticky notes).
111
+ * The no-modifier branch is algebraically identical to the previous
112
+ * min/max corner math (verified against the resize round-trip tests).
113
+ */
114
+ function bboxResize(
115
+ bbox: { x: number; y: number; w: number; h: number },
116
+ corner: Corner,
117
+ wx: number,
118
+ wy: number,
119
+ mods: ResizeMods,
120
+ square: boolean
121
+ ): { x: number; y: number; w: number; h: number } {
122
+ const { x, y, w, h } = bbox;
123
+ const cx = x + w / 2;
124
+ const cy = y + h / 2;
125
+ const isW = isWestCorner(corner);
126
+ const isN = isNorthCorner(corner);
127
+ const anchorX = isW ? x + w : x;
128
+ const anchorY = isN ? y + h : y;
129
+
130
+ // Raw extents from the dragged corner — center-relative under Alt.
131
+ let nw = mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX);
132
+ let nh = mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY);
133
+
134
+ if (square) {
135
+ const side = Math.max(nw, nh, 1);
136
+ nw = side;
137
+ nh = side;
138
+ } else if (mods.shift && w > 0 && h > 0) {
139
+ const s = Math.max(nw / w, nh / h);
140
+ nw = w * s;
141
+ nh = h * s;
142
+ }
143
+ nw = Math.max(1, nw);
144
+ nh = Math.max(1, nh);
145
+
146
+ let nx: number;
147
+ let ny: number;
148
+ if (mods.alt) {
149
+ nx = cx - nw / 2;
150
+ ny = cy - nh / 2;
151
+ } else {
152
+ // Grow away from the anchor toward the cursor's side (handles flips).
153
+ nx = wx < anchorX ? anchorX - nw : anchorX;
154
+ ny = wy < anchorY ? anchorY - nh : anchorY;
155
+ }
156
+ return { x: nx, y: ny, w: nw, h: nh };
157
+ }
158
+
75
159
  /**
76
160
  * Per-tool resize math. Given a stroke + the moved corner + the new world
77
161
  * coords for that corner, returns a patched stroke. `start` is the stroke at
78
162
  * the moment the drag began (used as the source-of-truth for scaling math —
79
- * avoids drift from rounding successive deltas).
163
+ * avoids drift from rounding successive deltas). `mods` carries the live
164
+ * FigJam resize modifiers (Shift aspect-lock, Alt scale-from-center).
80
165
  */
81
- function resizeStroke(
166
+ export function resizeStroke(
82
167
  start: Stroke,
83
168
  corner: Corner,
84
169
  wx: number,
85
- wy: number
170
+ wy: number,
171
+ mods: ResizeMods = NO_MODS
86
172
  ): Partial<Stroke> | null {
87
- if (start.tool === 'rect' || start.tool === 'sticky') {
88
- // Sticky resizes exactly like a rect it shares x / y / w / h. Text
89
- // re-wraps inside the foreignObject automatically (no special handling).
90
- const bbox = { x: start.x, y: start.y, w: start.w, h: start.h };
91
- const left = corner === 'nw' || corner === 'sw' ? wx : bbox.x;
92
- const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
93
- const top = corner === 'nw' || corner === 'ne' ? wy : bbox.y;
94
- const bottom = corner === 'sw' || corner === 'se' ? wy : bbox.y + bbox.h;
95
- return {
96
- x: Math.min(left, right),
97
- y: Math.min(top, bottom),
98
- w: Math.abs(right - left),
99
- h: Math.abs(bottom - top),
100
- } as Partial<RectStroke | StickyStroke>;
173
+ if (start.tool === 'rect' || start.tool === 'sticky' || start.tool === 'polygon') {
174
+ // Rect / polygon / sticky all resize via their shared x / y / w / h bbox.
175
+ // Text re-wraps inside the foreignObject automatically. Sticky stays 1:1.
176
+ const box = bboxResize(
177
+ { x: start.x, y: start.y, w: start.w, h: start.h },
178
+ corner,
179
+ wx,
180
+ wy,
181
+ mods,
182
+ start.tool === 'sticky'
183
+ );
184
+ return box as Partial<RectStroke | StickyStroke | PolygonStroke>;
101
185
  }
102
186
  if (start.tool === 'ellipse') {
103
- // Treat the four corners as the bbox of the ellipse. Drag any corner →
104
- // recompute the AABB and derive cx/cy/rx/ry from the diagonal anchor.
105
- const bbox = {
106
- x: start.cx - start.rx,
107
- y: start.cy - start.ry,
108
- w: start.rx * 2,
109
- h: start.ry * 2,
110
- };
111
- const left = corner === 'nw' || corner === 'sw' ? wx : bbox.x;
112
- const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
113
- const top = corner === 'nw' || corner === 'ne' ? wy : bbox.y;
114
- const bottom = corner === 'sw' || corner === 'se' ? wy : bbox.y + bbox.h;
115
- const nx = Math.min(left, right);
116
- const ny = Math.min(top, bottom);
117
- const nw = Math.abs(right - left);
118
- const nh = Math.abs(bottom - top);
187
+ // Treat the four corners as the bbox of the ellipse, then derive cx/cy/rx/ry.
188
+ const box = bboxResize(
189
+ { x: start.cx - start.rx, y: start.cy - start.ry, w: start.rx * 2, h: start.ry * 2 },
190
+ corner,
191
+ wx,
192
+ wy,
193
+ mods,
194
+ false
195
+ );
119
196
  return {
120
- cx: nx + nw / 2,
121
- cy: ny + nh / 2,
122
- rx: Math.max(1, nw / 2),
123
- ry: Math.max(1, nh / 2),
197
+ cx: box.x + box.w / 2,
198
+ cy: box.y + box.h / 2,
199
+ rx: Math.max(1, box.w / 2),
200
+ ry: Math.max(1, box.h / 2),
124
201
  } as Partial<EllipseStroke>;
125
202
  }
126
203
  if (start.tool === 'arrow') {
127
- if (corner === 'ep1') return { x1: wx, y1: wy } as Partial<ArrowStroke>;
128
- if (corner === 'ep2') return { x2: wx, y2: wy } as Partial<ArrowStroke>;
129
- return null;
204
+ if (corner !== 'ep1' && corner !== 'ep2') return null;
205
+ const otherX = corner === 'ep1' ? start.x2 : start.x1;
206
+ const otherY = corner === 'ep1' ? start.y2 : start.y1;
207
+ const midX = (start.x1 + start.x2) / 2;
208
+ const midY = (start.y1 + start.y2) / 2;
209
+ // Alt pins the midpoint (both ends mirror); otherwise the far end is fixed.
210
+ const refX = mods.alt ? midX : otherX;
211
+ const refY = mods.alt ? midY : otherY;
212
+ let dragX = wx;
213
+ let dragY = wy;
214
+ if (mods.shift) {
215
+ // Snap the shaft angle (relative to the reference) to 45° increments.
216
+ const dx = wx - refX;
217
+ const dy = wy - refY;
218
+ const dist = Math.hypot(dx, dy);
219
+ const step = Math.PI / 4;
220
+ const ang = Math.round(Math.atan2(dy, dx) / step) * step;
221
+ dragX = refX + Math.cos(ang) * dist;
222
+ dragY = refY + Math.sin(ang) * dist;
223
+ }
224
+ if (mods.alt) {
225
+ const mirrorX = 2 * midX - dragX;
226
+ const mirrorY = 2 * midY - dragY;
227
+ return corner === 'ep1'
228
+ ? ({ x1: dragX, y1: dragY, x2: mirrorX, y2: mirrorY } as Partial<ArrowStroke>)
229
+ : ({ x2: dragX, y2: dragY, x1: mirrorX, y1: mirrorY } as Partial<ArrowStroke>);
230
+ }
231
+ return corner === 'ep1'
232
+ ? ({ x1: dragX, y1: dragY } as Partial<ArrowStroke>)
233
+ : ({ x2: dragX, y2: dragY } as Partial<ArrowStroke>);
130
234
  }
131
235
  if (start.tool === 'pen') {
132
- // Scale all points by (newW / oldW, newH / oldH) around the opposite
133
- // corner anchor. When the drag-start bbox has 0 width/height on an axis
134
- // (single-point pen stroke) we skip that axis to avoid div-by-zero.
236
+ // Scale all points around an anchor the opposite corner (normal) or the
237
+ // bbox center (Alt). Shift forces a uniform scale (dominant axis wins).
238
+ // A 0-extent axis (single-point pen stroke) keeps scale 1 (no div-by-zero).
135
239
  const bb = strokeBBox(start);
136
240
  if (!bb) return null;
137
- const anchorX = corner === 'nw' || corner === 'sw' ? bb.x + bb.w : bb.x;
138
- const anchorY = corner === 'nw' || corner === 'ne' ? bb.y + bb.h : bb.y;
139
- const newLeft = corner === 'nw' || corner === 'sw' ? wx : bb.x;
140
- const newTop = corner === 'nw' || corner === 'ne' ? wy : bb.y;
141
- const newRight = corner === 'ne' || corner === 'se' ? wx : bb.x + bb.w;
142
- const newBottom = corner === 'sw' || corner === 'se' ? wy : bb.y + bb.h;
143
- const sx = bb.w === 0 ? 1 : (newRight - newLeft) / bb.w;
144
- const sy = bb.h === 0 ? 1 : (newBottom - newTop) / bb.h;
241
+ const cx = bb.x + bb.w / 2;
242
+ const cy = bb.y + bb.h / 2;
243
+ const isW = isWestCorner(corner);
244
+ const isN = isNorthCorner(corner);
245
+ let anchorX: number;
246
+ let anchorY: number;
247
+ let sx: number;
248
+ let sy: number;
249
+ if (mods.alt) {
250
+ anchorX = cx;
251
+ anchorY = cy;
252
+ sx = bb.w === 0 ? 1 : (wx - cx) / (isW ? -bb.w / 2 : bb.w / 2);
253
+ sy = bb.h === 0 ? 1 : (wy - cy) / (isN ? -bb.h / 2 : bb.h / 2);
254
+ } else {
255
+ anchorX = isW ? bb.x + bb.w : bb.x;
256
+ anchorY = isN ? bb.y + bb.h : bb.y;
257
+ const newLeft = isW ? wx : bb.x;
258
+ const newTop = isN ? wy : bb.y;
259
+ const newRight = isW ? bb.x + bb.w : wx;
260
+ const newBottom = isN ? bb.y + bb.h : wy;
261
+ sx = bb.w === 0 ? 1 : (newRight - newLeft) / bb.w;
262
+ sy = bb.h === 0 ? 1 : (newBottom - newTop) / bb.h;
263
+ }
264
+ if (mods.shift) {
265
+ const s = Math.max(Math.abs(sx), Math.abs(sy));
266
+ sx = (sx < 0 ? -1 : 1) * s;
267
+ sy = (sy < 0 ? -1 : 1) * s;
268
+ }
145
269
  const scaled = start.points.map(
146
270
  ([px, py]) =>
147
271
  [anchorX + (px - anchorX) * sx, anchorY + (py - anchorY) * sy] as [number, number]
@@ -189,6 +313,9 @@ export function AnnotationResizeOverlay({
189
313
  startStroke: Stroke;
190
314
  corner: Corner;
191
315
  } | null>(null);
316
+ // Last pointer position (client coords) during a drag — lets a mid-drag
317
+ // Shift/Alt keydown re-apply the resize without waiting for a pointermove.
318
+ const lastPointRef = useRef<{ x: number; y: number } | null>(null);
192
319
 
193
320
  const selectedId = annotSel.selectedIds.length === 1 ? (annotSel.selectedIds[0] ?? null) : null;
194
321
  const selectedStroke: Stroke | null = useMemo(() => {
@@ -274,28 +401,48 @@ export function AnnotationResizeOverlay({
274
401
  /* some browsers reject capture on synthetic events */
275
402
  }
276
403
  };
404
+ const applyResize = (clientX: number, clientY: number, mods: ResizeMods) => {
405
+ const d = dragRef.current;
406
+ if (!d || !store) return;
407
+ const [wx, wy] = screenToWorld(clientX, clientY);
408
+ const patch = resizeStroke(d.startStroke, d.corner, wx, wy, mods);
409
+ if (patch) store.updateStroke(d.startStroke.id, patch);
410
+ };
277
411
  const onMove = (e: PointerEvent) => {
278
412
  const d = dragRef.current;
279
413
  if (!d || e.pointerId !== d.pointerId) return;
280
- if (!store) return;
281
- const [wx, wy] = screenToWorld(e.clientX, e.clientY);
282
- const patch = resizeStroke(d.startStroke, d.corner, wx, wy);
283
- if (patch) store.updateStroke(d.startStroke.id, patch);
414
+ lastPointRef.current = { x: e.clientX, y: e.clientY };
415
+ applyResize(e.clientX, e.clientY, { shift: e.shiftKey, alt: e.altKey });
416
+ };
417
+ // Holding/releasing Shift or Alt mid-drag re-runs the resize at the last
418
+ // known pointer position so the constraint flips live, FigJam-style.
419
+ const onKey = (e: KeyboardEvent) => {
420
+ if (!dragRef.current) return;
421
+ if (e.key !== 'Shift' && e.key !== 'Alt') return;
422
+ const p = lastPointRef.current;
423
+ if (!p) return;
424
+ e.preventDefault();
425
+ applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey });
284
426
  };
285
427
  const onUp = (e: PointerEvent) => {
286
428
  const d = dragRef.current;
287
429
  if (!d || e.pointerId !== d.pointerId) return;
288
430
  dragRef.current = null;
431
+ lastPointRef.current = null;
289
432
  };
290
433
  c.addEventListener('pointerdown', onDown);
291
434
  document.addEventListener('pointermove', onMove);
292
435
  document.addEventListener('pointerup', onUp);
293
436
  document.addEventListener('pointercancel', onUp);
437
+ document.addEventListener('keydown', onKey, true);
438
+ document.addEventListener('keyup', onKey, true);
294
439
  return () => {
295
440
  c.removeEventListener('pointerdown', onDown);
296
441
  document.removeEventListener('pointermove', onMove);
297
442
  document.removeEventListener('pointerup', onUp);
298
443
  document.removeEventListener('pointercancel', onUp);
444
+ document.removeEventListener('keydown', onKey, true);
445
+ document.removeEventListener('keyup', onKey, true);
299
446
  };
300
447
  }, [selectedStroke, store, screenToWorld]);
301
448
 
@@ -36,6 +36,13 @@ export interface ToolDescriptor {
36
36
  cursor: string;
37
37
  }
38
38
 
39
+ /**
40
+ * Phase 24 — the six primitives the single Shape tool can draw. Maps onto the
41
+ * stroke model: square/rounded → `rect` (cornerRadius 0 / 8); circle →
42
+ * `ellipse`; diamond/triangle/triangle-down → `polygon`.
43
+ */
44
+ export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle' | 'triangle-down';
45
+
39
46
  // Phase 21 — every tool ships a custom 32×32 SVG cursor (canvas-cursors.ts)
40
47
  // with a white outline halo so the glyph reads on any background. The native
41
48
  // crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
@@ -45,8 +52,9 @@ export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
45
52
  { id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
46
53
  { id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
47
54
  { id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
48
- { id: 'rect', label: 'Rect', shortcut: 'R', cursor: TOOL_CURSORS.rect },
49
- { id: 'ellipse', label: 'Ellipse', shortcut: 'O', cursor: TOOL_CURSORS.ellipse },
55
+ // Phase 24 one Shape tool replaces the separate Rect (R) + Ellipse (O)
56
+ // buttons; the primitive is chosen from the palette popover.
57
+ { id: 'shape', label: 'Shape', shortcut: 'R', cursor: TOOL_CURSORS.shape },
50
58
  { id: 'sticky', label: 'Sticky', shortcut: 'N', cursor: TOOL_CURSORS.sticky },
51
59
  { id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: TOOL_CURSORS.arrow },
52
60
  { id: 'text', label: 'Text', shortcut: 'T', cursor: TOOL_CURSORS.text },
@@ -64,6 +72,9 @@ interface ToolContextValue {
64
72
  sticky: { tool: Tool | null; locked: boolean };
65
73
  toggleSticky: (t: Tool) => void;
66
74
  clearSticky: () => void;
75
+ /** Phase 24 — the primitive the Shape tool will draw next. */
76
+ shapeKind: ShapeKind;
77
+ setShapeKind: (k: ShapeKind) => void;
67
78
  }
68
79
 
69
80
  const ToolContext = createContext<ToolContextValue | null>(null);
@@ -101,25 +112,60 @@ export function ToolProvider({
101
112
  const clearSticky = useCallback(() => {
102
113
  setSticky({ tool: null, locked: false });
103
114
  }, []);
104
-
105
- // Body cursor sync — applied to the canvas iframe's body (this hook runs
106
- // inside the iframe context). The viewport-controller still owns the
107
- // grabbing/grab cursor swap during space-pan; this only sets the resting
108
- // cursor for the active tool.
115
+ const [shapeKind, setShapeKind] = useState<ShapeKind>('square');
116
+
117
+ // Cursor sync — applied inside the canvas (this hook runs in the canvas
118
+ // context). The active tool's cursor is set on <body> AND forced across the
119
+ // whole canvas working area via an `!important` rule, so the custom cursor
120
+ // shows EVERYWHERE — including over artboard CONTENT, whose own `cursor:
121
+ // pointer`/`text`/… would otherwise win (Phase 24, the "custom cursors in the
122
+ // whole app" requirement; FigJam behaviour). Chrome that lives OUTSIDE
123
+ // `.dc-world` (tool palette, context toolbar, resize handles) is intentionally
124
+ // NOT matched, so its buttons/handles keep their affordance cursors. The
125
+ // viewport-controller still owns the grab/grabbing swap during space-pan.
109
126
  useEffect(() => {
110
127
  if (typeof document === 'undefined') return;
111
128
  const desc = tools.find((t) => t.id === tool);
112
129
  if (!desc) return;
113
130
  const prev = document.body.style.cursor;
114
131
  document.body.style.cursor = desc.cursor;
132
+ let styleEl = document.getElementById('dc-tool-cursor') as HTMLStyleElement | null;
133
+ if (!styleEl) {
134
+ styleEl = document.createElement('style');
135
+ styleEl.id = 'dc-tool-cursor';
136
+ document.head.appendChild(styleEl);
137
+ }
138
+ // Truly GLOBAL inside the canvas document — `*` so it covers the empty grid
139
+ // host, `.dc-world`, every artboard + its content, AND the floating chrome
140
+ // (minimap, toolbar). The earlier `.dc-world`-scoped rule left the empty
141
+ // canvas / minimap on their own cursors; the brief is "prostě všude". (Mirrors
142
+ // the outer-shell `*` rule so both documents are uniformly covered.)
143
+ styleEl.textContent = `* { cursor: ${desc.cursor} !important; }`;
144
+ // Phase 24 — broadcast the active tool TOKEN to the OUTER app shell (this
145
+ // hook runs in the canvas iframe) so the shell shows the same custom cursor
146
+ // across the whole maude UI (sidebar / top bar). We send the tool *id*, NOT
147
+ // the cursor string: the shell resolves it against its own trusted
148
+ // TOOL_CURSORS copy (resolveToolCursor), so an untrusted synced canvas
149
+ // (DDR-054) can only pick a known, always-visible glyph — it can't inject an
150
+ // invisible/displaced cursor as a clickjacking aid (phase-24 ethical-hacker
151
+ // Finding 2; DDR-067).
152
+ if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
153
+ try {
154
+ window.parent.postMessage({ dgn: 'tool-cursor', tool }, '*');
155
+ } catch {
156
+ /* cross-origin parent rejected — shell keeps its default cursor */
157
+ }
158
+ }
115
159
  return () => {
116
160
  document.body.style.cursor = prev;
161
+ const el = document.getElementById('dc-tool-cursor');
162
+ if (el) el.textContent = '';
117
163
  };
118
164
  }, [tool, tools]);
119
165
 
120
166
  const value = useMemo<ToolContextValue>(
121
- () => ({ tool, setTool, tools, sticky, toggleSticky, clearSticky }),
122
- [tool, setTool, tools, sticky, toggleSticky, clearSticky]
167
+ () => ({ tool, setTool, tools, sticky, toggleSticky, clearSticky, shapeKind, setShapeKind }),
168
+ [tool, setTool, tools, sticky, toggleSticky, clearSticky, shapeKind]
123
169
  );
124
170
 
125
171
  return <ToolContext.Provider value={value}>{children}</ToolContext.Provider>;
@@ -65,27 +65,54 @@
65
65
  </style>
66
66
  <!-- Phase 6.5 export — when the shell is loaded with `&hide-chrome=1`
67
67
  (the exporters set this), suppress every dev-server overlay so the
68
- capture only carries the artboard content. The runtime chrome —
69
- tool palette, mini-world-map, comment pins, draw layer, snap guides,
70
- selection halos, the artboard label button — is not part of the
71
- design and would otherwise show up in PNG / PDF / SVG exports. -->
68
+ capture only carries the artboard content. The runtime chrome — tool
69
+ palette, mini-map, presence avatars + cursors, comment pins, draw
70
+ layer, snap guides, selection halos, toolbars, the artboard label
71
+ button — is not part of the design and would otherwise leak into PNG /
72
+ PDF / SVG exports. The selector list MUST track the live class names:
73
+ the previous list referenced `.dc-mini-map` / `.dc-world-map`, which
74
+ never existed (the real minimap is `.dc-mm`), and omitted the presence
75
+ avatars `.dc-participants`, so both leaked — see export-pipeline-fixes
76
+ Task 2. The bootstrap below flips this block's `media` from `not all`
77
+ to `all` only when `?hide-chrome=1`, so the live editor keeps chrome. -->
72
78
  <style id="canvas-hide-chrome" media="not all">
79
+ /* Minimap + zoom + tool palette (and its portaled popovers) */
80
+ .dc-mm,
81
+ .dc-zoom-tb,
73
82
  .dc-tool-palette,
83
+ .dc-tp-popover,
84
+ .dc-tp-shape-popover,
85
+ .dc-context-menu,
86
+ /* Presence — avatars (top-right) + live cursors */
87
+ .dc-participants,
88
+ .dc-cursor-overlay,
89
+ /* Selection + manipulation chrome */
74
90
  .dc-cv-halo,
75
91
  .dc-cv-group-bbox,
76
- .dc-mini-map,
77
- .dc-mini-world-map,
78
- .dc-world-map,
92
+ .dc-cv-eq-spacing-layer,
93
+ .dc-multi-artboard-tb,
94
+ .dc-elem-ctx-tb,
79
95
  .dc-snap-guide,
80
- .dc-annot-overlay,
96
+ .dc-snap-pill,
97
+ /* Annotations (FigJam-style draw layer) */
81
98
  .dc-annot-svg,
82
- .dc-annot-chrome,
83
99
  .dc-annot-ctx,
100
+ .dc-annot-resize-handle,
101
+ /* Comments — pins, threads, composer, @mention popup */
84
102
  .cm-layer,
85
103
  .cm-pin,
86
104
  .cm-thread,
87
105
  .cm-composer,
106
+ .cm-mention-popup,
107
+ /* Inspector pin overlay injected into every served shell (inspect.ts) */
108
+ .dgn-pin,
109
+ #dgn-pin-layer,
110
+ /* HUDs + banners */
111
+ .dc-ai-banner,
112
+ .dc-undo-hud,
113
+ /* Artboard title/label button (editor affordance, not design content) */
88
114
  .dc-artboard-label,
115
+ /* Generic opt-in hooks for any future floating overlay */
89
116
  [data-dc-overlay],
90
117
  [data-mdcc-annotations] { display: none !important; }
91
118
  </style>