@1agh/maude 0.23.0 → 0.24.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 (101) 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/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -34,9 +34,10 @@ import {
34
34
  import { createPortal } from 'react-dom';
35
35
 
36
36
  import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
37
+ import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
37
38
  import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
38
39
  import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
39
- import { crossedDragThreshold } from './input-router.tsx';
40
+ import { type Tool, crossedDragThreshold } from './input-router.tsx';
40
41
  import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
41
42
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
42
43
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
@@ -67,6 +68,8 @@ export interface RectStroke {
67
68
  w: number;
68
69
  h: number;
69
70
  fill?: string | null;
71
+ /** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
72
+ cornerRadius?: number;
70
73
  }
71
74
  export interface EllipseStroke {
72
75
  id: string;
@@ -88,6 +91,12 @@ export interface ArrowStroke {
88
91
  y1: number;
89
92
  x2: number;
90
93
  y2: number;
94
+ /** Phase 21 — head on the (x1,y1) start. Absent = 'none' (back-compat). */
95
+ startHead?: 'none' | 'triangle';
96
+ /** Phase 21 — head on the (x2,y2) end. Absent = 'triangle' (back-compat). */
97
+ endHead?: 'none' | 'triangle';
98
+ /** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
99
+ dashed?: boolean;
91
100
  }
92
101
  export interface TextStroke {
93
102
  id: string;
@@ -95,28 +104,83 @@ export interface TextStroke {
95
104
  color: string;
96
105
  fontSize: number;
97
106
  text: string;
98
- anchorId: string;
107
+ /**
108
+ * Host shape id for anchored text (double-click a rect/ellipse). Phase 21
109
+ * relaxed this to optional: standalone text (the `text` tool) carries no
110
+ * anchor and renders at its own world `(x, y)` instead.
111
+ */
112
+ anchorId?: string;
113
+ /** Phase 21 — world coords for standalone (unanchored) text. */
114
+ x?: number;
115
+ y?: number;
116
+ }
117
+ /** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
118
+ export interface StickyStroke {
119
+ id: string;
120
+ tool: 'sticky';
121
+ color: string;
122
+ x: number;
123
+ y: number;
124
+ w: number;
125
+ h: number;
126
+ text: string;
127
+ fontSize: number;
128
+ /** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
129
+ cornerRadius?: number;
99
130
  }
100
- export type Stroke = PenStroke | RectStroke | EllipseStroke | ArrowStroke | TextStroke;
101
-
102
- const PALETTE = [
103
- '#d63b1f', // accent red — first slot mirrors the default DS accent
104
- '#f5a623', // amber
105
- '#1a8f3e', // green
106
- '#1d6cf0', // blue
107
- '#7a4ad3', // purple
108
- '#1a1a1a', // ink
131
+ export type Stroke =
132
+ | PenStroke
133
+ | RectStroke
134
+ | EllipseStroke
135
+ | ArrowStroke
136
+ | TextStroke
137
+ | StickyStroke;
138
+
139
+ /**
140
+ * Phase 21 — what the inline editor is currently bound to. `anchored` edits
141
+ * the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
142
+ * re-edits a free text node; `pending` is a not-yet-born text caret (no stroke
143
+ * exists until real text is committed).
144
+ */
145
+ type EditingTarget =
146
+ | { kind: 'anchored'; anchorId: string; host: RectStroke | EllipseStroke }
147
+ | { kind: 'sticky'; sticky: StickyStroke }
148
+ | { kind: 'standalone'; text: TextStroke }
149
+ | { kind: 'pending'; x: number; y: number }
150
+ | null;
151
+
152
+ // Phase 21 colour system — a single coherent hue family used everywhere.
153
+ // FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are
154
+ // light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use
155
+ // their own lightened paper set (STICKY_PALETTE). Exported so the draw-time
156
+ // chrome AND the per-selection context toolbar share ONE palette instead of
157
+ // drifting apart.
158
+ export const STROKE_PALETTE = [
159
+ '#e5484d', // red (default — markup ink)
160
+ '#f2762a', // orange
161
+ '#e0a500', // amber
162
+ '#30a46c', // green
163
+ '#3b82f6', // blue
164
+ '#8b5cf6', // purple
165
+ '#e93d82', // pink
166
+ '#7c7c7c', // gray
167
+ '#1f1f1f', // ink
109
168
  ] as const;
110
- type PaletteColor = (typeof PALETTE)[number];
111
- const DEFAULT_COLOR: PaletteColor = PALETTE[0];
112
-
113
- const FILL_PALETTE = [
114
- '#fff4d6', // amber tint
115
- '#e6f4ea', // green tint
116
- '#e3edff', // blue tint
117
- '#f0e8fb', // purple tint
118
- '#ffe5e0', // red tint
119
- '#f4f1ee', // paper
169
+ type PaletteColor = (typeof STROKE_PALETTE)[number];
170
+ const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[0];
171
+
172
+ // Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
173
+ // pale blue wash under a saturated stroke, exactly like FigJam shapes.
174
+ export const FILL_PALETTE = [
175
+ '#fbe0e1', // red tint
176
+ '#fce6d6', // orange tint
177
+ '#fbeec2', // amber tint
178
+ '#d9f1e2', // green tint
179
+ '#e0ebfd', // blue tint
180
+ '#ebe3fc', // purple tint
181
+ '#fbdfeb', // pink tint
182
+ '#ededed', // gray tint
183
+ '#e7e7e7', // ink tint
120
184
  ] as const;
121
185
 
122
186
  const STROKE_WIDTH_THIN = 3;
@@ -126,6 +190,23 @@ type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
126
190
  const FONT_SIZE_MEDIUM = 14;
127
191
  const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
128
192
 
193
+ // Phase 21 — sticky-note paper tints. FigJam-style desaturated tints, wholly
194
+ // separate from the stroke ink PALETTE and the translucent FILL_PALETTE so
195
+ // stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
196
+ export const STICKY_PALETTE = [
197
+ '#ffe27a', // yellow (default — warm FigJam paper)
198
+ '#ffc1d4', // pink
199
+ '#a9d6ff', // blue
200
+ '#b3e6b8', // green
201
+ '#d8c2fb', // purple
202
+ '#f2ece1', // paper-white
203
+ ] as const;
204
+ const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
205
+ const STICKY_CORNER_RADIUS = 8;
206
+ const STICKY_DEFAULT_W = 200;
207
+ const STICKY_DEFAULT_H = 160;
208
+ const STICKY_MIN_SIZE = 40;
209
+
129
210
  // ─────────────────────────────────────────────────────────────────────────────
130
211
  // Pure helpers — exported for unit tests.
131
212
 
@@ -165,11 +246,39 @@ export function arrowHeadPoints(
165
246
 
166
247
  function strokeToSvgEl(s: Stroke): string {
167
248
  if (s.tool === 'text') {
168
- return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
169
- s.anchorId
170
- )}" data-font-size="${s.fontSize}" fill="${esc(
171
- s.color
172
- )}" text-anchor="middle" dominant-baseline="middle">${esc(s.text)}</text>`;
249
+ // Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
250
+ // standalone text (no anchorId) writes its own world x/y and omits
251
+ // data-anchor-id (so the parser routes it back to the standalone branch).
252
+ if (s.anchorId != null && s.anchorId !== '') {
253
+ return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
254
+ s.anchorId
255
+ )}" data-font-size="${s.fontSize}" fill="${esc(
256
+ s.color
257
+ )}" text-anchor="middle" dominant-baseline="middle">${esc(s.text)}</text>`;
258
+ }
259
+ const tx = s.x ?? 0;
260
+ const ty = s.y ?? 0;
261
+ return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
262
+ s.fontSize
263
+ }" fill="${esc(s.color)}" text-anchor="start" dominant-baseline="hanging">${esc(s.text)}</text>`;
264
+ }
265
+ if (s.tool === 'sticky') {
266
+ // Phase 21 — sticky body lives in an allowlisted <text> child so it
267
+ // survives sanitizeAnnotationSvg (which strips <foreignObject>, DDR-060
268
+ // F1). The live canvas re-renders this stroke with a foreignObject so the
269
+ // text word-wraps; the persisted <text> is the inert, sanitizer-safe form.
270
+ const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
271
+ const w = Math.max(0, s.w);
272
+ const h = Math.max(0, s.h);
273
+ return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
274
+ s.fontSize
275
+ }" fill="${esc(s.color)}"><rect x="${s.x}" y="${
276
+ s.y
277
+ }" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
278
+ s.x + 12
279
+ }" y="${s.y + 12}" font-size="${
280
+ s.fontSize
281
+ }" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
173
282
  }
174
283
  const common = `data-id="${esc(s.id)}" data-tool="${s.tool}" stroke="${esc(s.color)}" stroke-width="${s.width}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`;
175
284
  if (s.tool === 'pen') {
@@ -177,10 +286,14 @@ function strokeToSvgEl(s: Stroke): string {
177
286
  }
178
287
  if (s.tool === 'rect') {
179
288
  const fill = s.fill ? esc(s.fill) : 'none';
289
+ // Phase 21 — corner radius: append rx/ry/data-r ONLY when > 0 so legacy
290
+ // sharp-corner rects serialize byte-identically (Task 10 canary).
291
+ const r = s.cornerRadius ?? 0;
292
+ const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
180
293
  return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
181
294
  0,
182
295
  s.w
183
- )}" height="${Math.max(0, s.h)}"/>`;
296
+ )}" height="${Math.max(0, s.h)}"${round}/>`;
184
297
  }
185
298
  if (s.tool === 'ellipse') {
186
299
  const fill = s.fill ? esc(s.fill) : 'none';
@@ -189,8 +302,30 @@ function strokeToSvgEl(s: Stroke): string {
189
302
  s.rx
190
303
  )}" ry="${Math.max(0, s.ry)}"/>`;
191
304
  }
192
- const head = arrowHeadPoints(s.x1, s.y1, s.x2, s.y2, s.width);
193
- return `<g ${common} fill="none"><line x1="${s.x1}" y1="${s.y1}" x2="${s.x2}" y2="${s.y2}"/><polyline points="${head}" fill="${esc(s.color)}"/></g>`;
305
+ // arrow Phase 21 adds optional start/end heads + dash. Defaults
306
+ // (startHead 'none', endHead 'triangle', solid) emit the byte-identical
307
+ // Phase 5.1 form: data-* attrs + extra polyline + dasharray appear only for
308
+ // non-default values.
309
+ const endHead = s.endHead ?? 'triangle';
310
+ const startHead = s.startHead ?? 'none';
311
+ const dashed = s.dashed ?? false;
312
+ const dataAttrs =
313
+ (startHead !== 'none' ? ` data-start-head="${startHead}"` : '') +
314
+ (endHead !== 'triangle' ? ` data-end-head="${endHead}"` : '') +
315
+ (dashed ? ' data-dash="1"' : '');
316
+ const dash = dashed ? ' stroke-dasharray="6 4"' : '';
317
+ const heads =
318
+ (startHead === 'triangle'
319
+ ? `<polyline points="${arrowHeadPoints(s.x2, s.y2, s.x1, s.y1, s.width)}" fill="${esc(
320
+ s.color
321
+ )}"/>`
322
+ : '') +
323
+ (endHead === 'triangle'
324
+ ? `<polyline points="${arrowHeadPoints(s.x1, s.y1, s.x2, s.y2, s.width)}" fill="${esc(
325
+ s.color
326
+ )}"/>`
327
+ : '');
328
+ return `<g ${common} fill="none"${dataAttrs}><line x1="${s.x1}" y1="${s.y1}" x2="${s.x2}" y2="${s.y2}"${dash}/>${heads}</g>`;
194
329
  }
195
330
 
196
331
  export function strokesToSvg(strokes: readonly Stroke[]): string {
@@ -238,13 +373,44 @@ export function svgToStrokes(svgText: string): Stroke[] {
238
373
  if (points.length) out.push({ id, tool: 'pen', color, width, points });
239
374
  continue;
240
375
  }
376
+ if (tool === 'sticky') {
377
+ // Phase 21 — sticky reads geometry off its <rect> child, paper tint
378
+ // off the group fill, body text off the inner <text>.
379
+ const rectEl = el.querySelector('rect');
380
+ const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
381
+ const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
382
+ const w = Number.parseFloat(rectEl?.getAttribute('width') || '0');
383
+ const h = Number.parseFloat(rectEl?.getAttribute('height') || '0');
384
+ const cornerRadius =
385
+ Number.parseFloat(el.getAttribute('data-r') || String(STICKY_CORNER_RADIUS)) || 0;
386
+ const fontSize =
387
+ Number.parseFloat(el.getAttribute('data-fs') || String(DEFAULT_FONT_SIZE)) ||
388
+ DEFAULT_FONT_SIZE;
389
+ const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
390
+ const body = el.querySelector('text');
391
+ out.push({
392
+ id,
393
+ tool: 'sticky',
394
+ color: stickyColor,
395
+ x,
396
+ y,
397
+ w,
398
+ h,
399
+ text: body?.textContent ?? '',
400
+ fontSize,
401
+ cornerRadius,
402
+ });
403
+ continue;
404
+ }
241
405
  if (tool === 'rect') {
242
406
  const x = Number.parseFloat(el.getAttribute('x') || '0');
243
407
  const y = Number.parseFloat(el.getAttribute('y') || '0');
244
408
  const w = Number.parseFloat(el.getAttribute('width') || '0');
245
409
  const h = Number.parseFloat(el.getAttribute('height') || '0');
246
410
  const fill = parseFill(el.getAttribute('fill'));
247
- out.push({ id, tool: 'rect', color, width, x, y, w, h, fill });
411
+ // Phase 21 corner radius; absent 0 (sharp, back-compat).
412
+ const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
413
+ out.push({ id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius });
248
414
  continue;
249
415
  }
250
416
  if (tool === 'ellipse') {
@@ -259,7 +425,7 @@ export function svgToStrokes(svgText: string): Stroke[] {
259
425
  if (tool === 'arrow') {
260
426
  const line = el.querySelector('line');
261
427
  if (line) {
262
- out.push({
428
+ const arrow: ArrowStroke = {
263
429
  id,
264
430
  tool: 'arrow',
265
431
  color,
@@ -268,23 +434,47 @@ export function svgToStrokes(svgText: string): Stroke[] {
268
434
  y1: Number.parseFloat(line.getAttribute('y1') || '0'),
269
435
  x2: Number.parseFloat(line.getAttribute('x2') || '0'),
270
436
  y2: Number.parseFloat(line.getAttribute('y2') || '0'),
271
- });
437
+ };
438
+ // Phase 21 — heads + dash. Serializer only writes a data-* attribute
439
+ // for a NON-default value, so a legacy arrow carries none of these
440
+ // and stays { startHead/endHead/dashed: undefined } → defaults on
441
+ // re-serialize (byte-identical, Task 10 canary).
442
+ if (el.getAttribute('data-start-head') === 'triangle') arrow.startHead = 'triangle';
443
+ if (el.getAttribute('data-end-head') === 'none') arrow.endHead = 'none';
444
+ const dashRaw = el.getAttribute('data-dash');
445
+ if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
446
+ out.push(arrow);
272
447
  }
273
448
  continue;
274
449
  }
275
450
  if (tool === 'text') {
276
- const anchorId = el.getAttribute('data-anchor-id') || '';
451
+ const rawAnchor = el.getAttribute('data-anchor-id');
277
452
  const fontSize =
278
453
  Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
279
454
  DEFAULT_FONT_SIZE;
280
455
  const inkColor = el.getAttribute('fill') || color;
456
+ const body = (el.textContent || '').trim();
457
+ // Phase 21 — standalone text (no data-anchor-id) carries world x/y
458
+ // instead of a host id.
459
+ if (!rawAnchor) {
460
+ out.push({
461
+ id,
462
+ tool: 'text',
463
+ color: inkColor,
464
+ fontSize,
465
+ text: body,
466
+ x: Number.parseFloat(el.getAttribute('x') || '0'),
467
+ y: Number.parseFloat(el.getAttribute('y') || '0'),
468
+ });
469
+ continue;
470
+ }
281
471
  out.push({
282
472
  id,
283
473
  tool: 'text',
284
474
  color: inkColor,
285
475
  fontSize,
286
- text: (el.textContent || '').trim(),
287
- anchorId,
476
+ text: body,
477
+ anchorId: rawAnchor,
288
478
  });
289
479
  }
290
480
  }
@@ -312,8 +502,26 @@ function pointSegmentDist(
312
502
  }
313
503
 
314
504
  export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
315
- if (s.tool === 'text') return false;
505
+ if (s.tool === 'text') {
506
+ // Anchored text isn't independently hit-testable (it inherits its host).
507
+ // Standalone text (Phase 21) uses its synthetic bbox so the eraser can
508
+ // reach it.
509
+ if (s.anchorId != null && s.anchorId !== '') return false;
510
+ const bb = strokeBBox(s);
511
+ if (!bb) return false;
512
+ return (
513
+ wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
514
+ );
515
+ }
316
516
  const t = Math.max(tol, 'width' in s ? s.width : 2);
517
+ if (s.tool === 'sticky') {
518
+ // Sticky is a solid paper card — filled-rect hit anywhere inside.
519
+ const xMin = Math.min(s.x, s.x + s.w);
520
+ const xMax = Math.max(s.x, s.x + s.w);
521
+ const yMin = Math.min(s.y, s.y + s.h);
522
+ const yMax = Math.max(s.y, s.y + s.h);
523
+ return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
524
+ }
317
525
  if (s.tool === 'pen') {
318
526
  if (s.points.length === 1) {
319
527
  const p = s.points[0] as WorldPoint;
@@ -362,7 +570,8 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
362
570
  return onLeft || onRight || onTop || onBottom;
363
571
  }
364
572
 
365
- function normalizeRect(r: RectStroke): RectStroke {
573
+ /** Flip a negative-extent box so x/y is the top-left and w/h are positive. */
574
+ function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(r: T): T {
366
575
  if (r.w >= 0 && r.h >= 0) return r;
367
576
  return {
368
577
  ...r,
@@ -373,11 +582,23 @@ function normalizeRect(r: RectStroke): RectStroke {
373
582
  };
374
583
  }
375
584
 
585
+ function normalizeRect(r: RectStroke): RectStroke {
586
+ return normalizeBox(r);
587
+ }
588
+
589
+ // Phase 21 — sticky shares rect's drag-to-create flip (x = min, w = abs(w)).
590
+ function normalizeSticky(s: StickyStroke): StickyStroke {
591
+ return normalizeBox(s);
592
+ }
593
+
376
594
  function isStrokeMeaningful(s: Stroke): boolean {
377
595
  if (s.tool === 'pen') return s.points.length >= 2;
378
596
  if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
379
597
  if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
380
598
  if (s.tool === 'text') return s.text.trim().length > 0;
599
+ // Sticky below a readable floor is discarded like a 2×2 rect.
600
+ if (s.tool === 'sticky')
601
+ return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
381
602
  return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
382
603
  }
383
604
 
@@ -418,9 +639,29 @@ export function strokeBBox(
418
639
  h: Math.abs(s.y2 - s.y1),
419
640
  };
420
641
  }
421
- // text inherit from anchor
422
- const host = anchors?.get(s.anchorId);
423
- return host ? strokeBBox(host) : null;
642
+ if (s.tool === 'sticky') {
643
+ return {
644
+ x: Math.min(s.x, s.x + s.w),
645
+ y: Math.min(s.y, s.y + s.h),
646
+ w: Math.abs(s.w),
647
+ h: Math.abs(s.h),
648
+ };
649
+ }
650
+ // text — anchored inherits its host's bbox; standalone (Phase 21) gets a
651
+ // synthetic bbox from its world (x, y) so it's selectable and the context
652
+ // toolbar can position against it.
653
+ if (s.anchorId != null && s.anchorId !== '') {
654
+ const host = anchors?.get(s.anchorId);
655
+ return host ? strokeBBox(host) : null;
656
+ }
657
+ const tx = s.x ?? 0;
658
+ const ty = s.y ?? 0;
659
+ return {
660
+ x: tx,
661
+ y: ty,
662
+ w: Math.max(8, s.text.length * s.fontSize * 0.55),
663
+ h: s.fontSize * 1.2,
664
+ };
424
665
  }
425
666
 
426
667
  function isEditable(t: EventTarget | null): boolean {
@@ -454,93 +695,113 @@ function deriveFile(): string | undefined {
454
695
  const ANNOT_CSS = `
455
696
  .dc-annot-chrome {
456
697
  /* Stacks directly above the centered tool toolbar (which is bottom:16px,
457
- 32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px. */
698
+ 32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px.
699
+ Phase 21 — dark "marker tray" matching the FigJam selection bar. */
458
700
  position: absolute;
459
701
  left: 50%;
460
- bottom: 60px;
702
+ bottom: 64px;
461
703
  transform: translateX(-50%);
462
704
  display: flex;
463
705
  align-items: center;
464
- gap: 8px;
465
- background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
466
- border: 1px solid var(--u-fg-0, #1c1917);
467
- border-radius: 8px;
468
- padding: 6px 10px;
469
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
706
+ gap: 2px;
707
+ background: #26262b;
708
+ border: 1px solid rgba(255,255,255,0.08);
709
+ border-radius: 12px;
710
+ padding: 5px 8px;
711
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
470
712
  font-size: 11px;
471
- color: var(--u-fg-0, rgba(40,30,20,0.85));
713
+ color: rgba(255,255,255,0.85);
472
714
  z-index: 6;
473
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
715
+ box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
474
716
  user-select: none;
475
717
  }
476
- .dc-annot-chrome .dc-annot-swatches { display: flex; gap: 4px; }
718
+ .dc-annot-chrome .dc-annot-swatches { display: flex; align-items: center; gap: 1px; }
477
719
  .dc-annot-chrome .dc-annot-sw {
478
- width: 18px;
479
- height: 18px;
480
- border-radius: 3px;
481
- border: 1px solid rgba(0,0,0,0.12);
720
+ width: 20px;
721
+ height: 20px;
722
+ border-radius: 50%;
723
+ border: 1px solid rgba(255,255,255,0.16);
482
724
  cursor: pointer;
483
725
  padding: 0;
484
726
  appearance: none;
727
+ transition: transform 80ms ease;
485
728
  }
729
+ .dc-annot-chrome .dc-annot-sw:hover { transform: scale(1.1); }
486
730
  .dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
487
- box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
731
+ box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
488
732
  border-color: transparent;
489
733
  }
490
734
  .dc-annot-chrome .dc-annot-sw:focus-visible {
491
- outline: 2px solid var(--maude-hud-accent, #d63b1f);
492
- outline-offset: 2px;
735
+ outline: 2px solid #ffffff;
736
+ outline-offset: 1px;
493
737
  }
494
738
  .dc-annot-chrome .dc-annot-sep {
495
739
  width: 1px;
496
- align-self: stretch;
497
- background: rgba(0,0,0,0.08);
498
- margin: 0 2px;
740
+ height: 16px;
741
+ align-self: center;
742
+ background: rgba(255,255,255,0.09);
743
+ margin: 0 4px;
499
744
  }
500
745
  .dc-annot-chrome .dc-annot-fill {
501
- width: 18px;
502
- height: 18px;
503
- border-radius: 3px;
504
- border: 1px solid rgba(0,0,0,0.18);
746
+ width: 20px;
747
+ height: 20px;
748
+ border-radius: 50%;
749
+ border: 1px solid rgba(255,255,255,0.16);
505
750
  cursor: pointer;
506
751
  padding: 0;
507
752
  appearance: none;
508
753
  position: relative;
754
+ transition: transform 80ms ease;
755
+ }
756
+ .dc-annot-chrome .dc-annot-fill:hover { transform: scale(1.1); }
757
+ .dc-annot-chrome .dc-annot-fill:focus-visible {
758
+ outline: 2px solid #ffffff;
759
+ outline-offset: 1px;
509
760
  }
510
761
  .dc-annot-chrome .dc-annot-fill--none {
511
- background: #fff;
762
+ background: #3a3a40;
512
763
  }
513
764
  .dc-annot-chrome .dc-annot-fill--none::after {
514
765
  content: "";
515
- position: absolute; inset: 2px;
766
+ position: absolute; inset: 4px;
767
+ border-radius: 50%;
516
768
  background:
517
- linear-gradient(135deg, transparent 47%, #d63b1f 47%, #d63b1f 53%, transparent 53%);
769
+ linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
518
770
  }
519
771
  .dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
520
- box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
772
+ box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
521
773
  border-color: transparent;
522
774
  }
523
- .dc-annot-chrome .dc-annot-btn {
775
+ /* Phase 21 — icon buttons (light glyph on dark, white-tint active). */
776
+ .dc-annot-chrome .dc-annot-ibtn {
524
777
  appearance: none;
525
778
  background: transparent;
526
- border: 1px solid rgba(0,0,0,0.12);
527
- border-radius: 3px;
528
- padding: 4px 8px;
529
- font: inherit;
530
- color: inherit;
779
+ border: 0;
780
+ border-radius: 7px;
781
+ width: 26px;
782
+ height: 26px;
783
+ display: inline-flex;
784
+ align-items: center;
785
+ justify-content: center;
786
+ color: rgba(255,255,255,0.78);
531
787
  cursor: pointer;
532
- letter-spacing: 0.05em;
533
- text-transform: uppercase;
788
+ padding: 0;
789
+ transition: background-color 80ms linear, color 80ms linear;
534
790
  }
535
- .dc-annot-chrome .dc-annot-btn[aria-pressed="true"] {
536
- background: var(--maude-hud-accent, #d63b1f);
537
- color: var(--maude-hud-accent-fg, #fff);
538
- border-color: transparent;
791
+ .dc-annot-chrome .dc-annot-ibtn:hover {
792
+ background: rgba(255,255,255,0.1);
793
+ color: #ffffff;
794
+ }
795
+ .dc-annot-chrome .dc-annot-ibtn[aria-pressed="true"] {
796
+ background: rgba(255,255,255,0.18);
797
+ color: #ffffff;
798
+ }
799
+ .dc-annot-chrome .dc-annot-ibtn:focus-visible {
800
+ outline: 2px solid #ffffff;
801
+ outline-offset: -2px;
539
802
  }
540
- .dc-annot-chrome .dc-annot-btn:hover { background: rgba(0,0,0,0.04); }
541
- .dc-annot-chrome .dc-annot-btn:focus-visible {
542
- outline: 2px solid var(--maude-hud-accent, #d63b1f);
543
- outline-offset: 2px;
803
+ @media (prefers-reduced-motion: reduce) {
804
+ .dc-annot-chrome .dc-annot-ibtn, .dc-annot-chrome .dc-annot-sw, .dc-annot-chrome .dc-annot-fill { transition: none; }
544
805
  }
545
806
  .dc-annot-input {
546
807
  position: absolute;
@@ -573,6 +834,25 @@ const ANNOT_CSS = `
573
834
  stroke-width: 1;
574
835
  stroke-dasharray: 4 3;
575
836
  }
837
+ /* Phase 21 — sticky-note body. Word-wrapped multi-line text inside the card's
838
+ foreignObject. The editor textarea (.dc-sticky-editor) mirrors the same box
839
+ metrics so the read↔edit swap doesn't shift the text. */
840
+ .dc-sticky-body {
841
+ width: 100%;
842
+ height: 100%;
843
+ box-sizing: border-box;
844
+ padding: 14px 16px;
845
+ display: flex;
846
+ align-items: center;
847
+ justify-content: center;
848
+ text-align: center;
849
+ color: #2a2a28;
850
+ font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
851
+ line-height: 1.35;
852
+ white-space: pre-wrap;
853
+ overflow-wrap: anywhere;
854
+ overflow: hidden;
855
+ }
576
856
  `.trim();
577
857
 
578
858
  function ensureAnnotStyles(): void {
@@ -610,7 +890,11 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
610
890
  if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
611
891
  if (s.tool === 'arrow')
612
892
  return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
613
- return s; // text inherits its host's bbox
893
+ if (s.tool === 'sticky') return { ...s, x: s.x + dx, y: s.y + dy };
894
+ // text — anchored inherits its host's bbox (moves with the host); standalone
895
+ // (Phase 21) carries its own world (x, y) and translates directly.
896
+ if (s.anchorId != null && s.anchorId !== '') return s;
897
+ return { ...s, x: (s.x ?? 0) + dx, y: (s.y ?? 0) + dy };
614
898
  }
615
899
 
616
900
  /**
@@ -640,7 +924,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
640
924
 
641
925
  export function AnnotationsLayer() {
642
926
  ensureAnnotStyles();
643
- const { tool, setTool, sticky } = useToolMode();
927
+ const { tool, setTool, sticky, tools } = useToolMode();
644
928
  const controller = useViewportControllerContext();
645
929
  const vp = controller?.viewport ?? null;
646
930
  const worldRef = useWorldRefContext();
@@ -652,6 +936,13 @@ export function AnnotationsLayer() {
652
936
  const [color, setColor] = useState<string>(DEFAULT_COLOR);
653
937
  const [fill, setFill] = useState<string | null>(null);
654
938
  const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
939
+ // Phase 21 — draw-time paper tint for the sticky tool (recolor-after via the
940
+ // context toolbar). Separate from `color` (ink for pen/rect/text/arrow).
941
+ const [stickyColor, setStickyColor] = useState<string>(DEFAULT_STICKY_COLOR);
942
+ // Phase 21 — a standalone-text caret waiting for its first keystroke. No
943
+ // stroke exists yet (mirrors anchored text: the stroke is born on commit,
944
+ // so an abandoned empty caret leaves nothing behind / no undo record).
945
+ const [pendingText, setPendingText] = useState<{ x: number; y: number } | null>(null);
655
946
  const visibilityCtx = useAnnotationsVisibility();
656
947
  const visible = visibilityCtx?.visible ?? true;
657
948
  const setVisible = useCallback(
@@ -679,7 +970,13 @@ export function AnnotationsLayer() {
679
970
  const strokesRef = useRef<Stroke[]>(strokes);
680
971
  strokesRef.current = strokes;
681
972
 
682
- const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
973
+ const isDraw =
974
+ tool === 'pen' ||
975
+ tool === 'rect' ||
976
+ tool === 'arrow' ||
977
+ tool === 'ellipse' ||
978
+ tool === 'sticky' ||
979
+ tool === 'text';
683
980
  const isErase = tool === 'eraser';
684
981
  const isActive = isDraw || isErase;
685
982
  // T20 — rect + ellipse expose stroke weight too (FigJam ships thickness on
@@ -835,7 +1132,7 @@ export function AnnotationsLayer() {
835
1132
  const set = new Set(ids);
836
1133
  const prev = strokesRef.current;
837
1134
  const next = prev.filter(
838
- (s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
1135
+ (s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
839
1136
  );
840
1137
  if (next.length === prev.length) return;
841
1138
  commitStrokes(prev, next);
@@ -983,6 +1280,30 @@ export function AnnotationsLayer() {
983
1280
  x2: wx,
984
1281
  y2: wy,
985
1282
  });
1283
+ } else if (tool === 'sticky') {
1284
+ // Phase 21 — drag-create a paper card. Default size if the user just
1285
+ // taps (no drag) is applied in endStroke.
1286
+ setDrawing({
1287
+ id,
1288
+ tool: 'sticky',
1289
+ color: stickyColor,
1290
+ x: wx,
1291
+ y: wy,
1292
+ w: 0,
1293
+ h: 0,
1294
+ text: '',
1295
+ fontSize: DEFAULT_FONT_SIZE,
1296
+ cornerRadius: STICKY_CORNER_RADIUS,
1297
+ });
1298
+ } else if (tool === 'text') {
1299
+ // Phase 21 — single click drops an editable caret at the click point.
1300
+ // No stroke is created until the user commits real text (mirrors the
1301
+ // anchored double-click flow), so an empty caret leaves nothing behind.
1302
+ setPendingText({ x: wx, y: wy });
1303
+ if (annotSel) annotSel.clear();
1304
+ const stickyOnText = sticky.locked && sticky.tool === 'text';
1305
+ if (!stickyOnText) setTool('move');
1306
+ return true;
986
1307
  }
987
1308
  return true;
988
1309
  },
@@ -991,6 +1312,7 @@ export function AnnotationsLayer() {
991
1312
  color,
992
1313
  fill,
993
1314
  thickness,
1315
+ stickyColor,
994
1316
  supportsThickness,
995
1317
  supportsFill,
996
1318
  isActive,
@@ -998,6 +1320,9 @@ export function AnnotationsLayer() {
998
1320
  visible,
999
1321
  screenToWorld,
1000
1322
  eraseAt,
1323
+ annotSel,
1324
+ sticky,
1325
+ setTool,
1001
1326
  ]
1002
1327
  );
1003
1328
 
@@ -1028,6 +1353,9 @@ export function AnnotationsLayer() {
1028
1353
  if (cur.tool === 'arrow') {
1029
1354
  return { ...cur, x2: wx, y2: wy };
1030
1355
  }
1356
+ if (cur.tool === 'sticky') {
1357
+ return { ...cur, w: wx - cur.x, h: wy - cur.y };
1358
+ }
1031
1359
  return cur;
1032
1360
  });
1033
1361
  },
@@ -1041,6 +1369,15 @@ export function AnnotationsLayer() {
1041
1369
  if (!cur) return;
1042
1370
  let final: Stroke | null = cur;
1043
1371
  if (cur.tool === 'rect') final = normalizeRect(cur);
1372
+ else if (cur.tool === 'sticky') {
1373
+ const norm = normalizeSticky(cur);
1374
+ // A bare tap (or a drag too small to be a usable card) drops a
1375
+ // default-sized note at the tap point — FigJam parity.
1376
+ final =
1377
+ Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
1378
+ ? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
1379
+ : norm;
1380
+ }
1044
1381
  if (final && !isStrokeMeaningful(final)) final = null;
1045
1382
  if (final) {
1046
1383
  const committed = final;
@@ -1051,6 +1388,9 @@ export function AnnotationsLayer() {
1051
1388
  // see + adjust it. annotSel is optional (some test harnesses mount
1052
1389
  // AnnotationsLayer without the provider), so guard the call.
1053
1390
  if (annotSel) annotSel.replace(committed.id);
1391
+ // Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
1392
+ // note, type immediately). Only meaningful deviation from rect/ellipse.
1393
+ if (committed.tool === 'sticky') setEditingId(committed.id);
1054
1394
  }
1055
1395
  // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
1056
1396
  // mode is locked on this tool. Sticky lets the user draw many shapes in a
@@ -1144,7 +1484,12 @@ export function AnnotationsLayer() {
1144
1484
  if (
1145
1485
  id &&
1146
1486
  t &&
1147
- (t === 'pen' || t === 'rect' || t === 'ellipse' || t === 'arrow' || t === 'text')
1487
+ (t === 'pen' ||
1488
+ t === 'rect' ||
1489
+ t === 'ellipse' ||
1490
+ t === 'arrow' ||
1491
+ t === 'text' ||
1492
+ t === 'sticky')
1148
1493
  ) {
1149
1494
  return id;
1150
1495
  }
@@ -1155,7 +1500,7 @@ export function AnnotationsLayer() {
1155
1500
  // the main tool palette, the in-canvas draw chrome, the minimap, and the
1156
1501
  // right-click menu. Clicks on these route to their own handlers.
1157
1502
  const CHROME_SELECTOR =
1158
- '.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle';
1503
+ '.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-editor';
1159
1504
 
1160
1505
  const onDown = (e: PointerEvent) => {
1161
1506
  if (e.button !== 0) return;
@@ -1274,7 +1619,10 @@ export function AnnotationsLayer() {
1274
1619
  const yMax = Math.max(final.ay, final.by);
1275
1620
  const hits: string[] = [];
1276
1621
  for (const s of strokesStoreRef.current.strokes) {
1277
- if (s.tool === 'text') continue; // text inherits its host's bbox
1622
+ // Anchored text inherits its host's bbox (selected with the host);
1623
+ // standalone text (Phase 21) has its own synthetic bbox and IS
1624
+ // marquee-selectable.
1625
+ if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
1278
1626
  const bb = strokeBBox(s);
1279
1627
  if (!bb) continue;
1280
1628
  if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
@@ -1302,7 +1650,9 @@ export function AnnotationsLayer() {
1302
1650
  const strokesStoreRef = useRef(strokesStore);
1303
1651
  strokesStoreRef.current = strokesStore;
1304
1652
 
1305
- // Double-click on a selected rect/ellipse enters text-edit mode.
1653
+ // Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
1654
+ // (its own body), or a standalone text node (re-edit in place). Anchored text
1655
+ // nodes are edited via their host, so a data-anchor-id text node is skipped.
1306
1656
  useEffect(() => {
1307
1657
  if (typeof document === 'undefined') return;
1308
1658
  if (tool !== 'move') return;
@@ -1312,7 +1662,13 @@ export function AnnotationsLayer() {
1312
1662
  if (!node) return;
1313
1663
  const id = node.getAttribute('data-id');
1314
1664
  const t = node.getAttribute('data-tool');
1315
- if (id && (t === 'rect' || t === 'ellipse')) {
1665
+ if (!id) return;
1666
+ if (t === 'rect' || t === 'ellipse' || t === 'sticky') {
1667
+ e.preventDefault();
1668
+ setEditingId(id);
1669
+ return;
1670
+ }
1671
+ if (t === 'text' && !node.getAttribute('data-anchor-id')) {
1316
1672
  e.preventDefault();
1317
1673
  setEditingId(id);
1318
1674
  }
@@ -1356,6 +1712,103 @@ export function AnnotationsLayer() {
1356
1712
  [commitStrokes]
1357
1713
  );
1358
1714
 
1715
+ // Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
1716
+ // no trim) and the card persists even when blank, so this only updates text.
1717
+ const commitStickyText = useCallback(
1718
+ (id: string, text: string) => {
1719
+ const prev = strokesRef.current;
1720
+ const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
1721
+ | StickyStroke
1722
+ | undefined;
1723
+ if (!existing || existing.text === text) return;
1724
+ const next = prev.map((s) => (s.id === id ? { ...existing, text } : s));
1725
+ commitStrokes(prev, next, 'edit sticky');
1726
+ },
1727
+ [commitStrokes]
1728
+ );
1729
+
1730
+ // Phase 21 — re-edit an EXISTING standalone text node. Empty text deletes it
1731
+ // (same rule as anchored text).
1732
+ const commitStandaloneText = useCallback(
1733
+ (id: string, text: string) => {
1734
+ const trimmed = text.trim();
1735
+ const prev = strokesRef.current;
1736
+ const existing = prev.find((s) => s.id === id && s.tool === 'text') as TextStroke | undefined;
1737
+ if (!existing) return;
1738
+ if (trimmed.length === 0) {
1739
+ commitStrokes(
1740
+ prev,
1741
+ prev.filter((s) => s.id !== id),
1742
+ 'delete text'
1743
+ );
1744
+ return;
1745
+ }
1746
+ if (existing.text === trimmed) return;
1747
+ commitStrokes(
1748
+ prev,
1749
+ prev.map((s) => (s.id === id ? { ...existing, text: trimmed } : s)),
1750
+ 'edit text'
1751
+ );
1752
+ },
1753
+ [commitStrokes]
1754
+ );
1755
+
1756
+ // Phase 21 — born-on-commit standalone text (from the text-tool caret). An
1757
+ // empty caret persists nothing — ONE undo record only when real text lands.
1758
+ const createStandaloneText = useCallback(
1759
+ (x: number, y: number, text: string) => {
1760
+ const trimmed = text.trim();
1761
+ if (trimmed.length === 0) return;
1762
+ const prev = strokesRef.current;
1763
+ const id = rid();
1764
+ const next: Stroke[] = [
1765
+ ...prev,
1766
+ { id, tool: 'text', color, fontSize: DEFAULT_FONT_SIZE, text: trimmed, x, y },
1767
+ ];
1768
+ commitStrokes(prev, next, 'add text');
1769
+ if (annotSel) annotSel.replace(id);
1770
+ },
1771
+ [commitStrokes, color, annotSel]
1772
+ );
1773
+
1774
+ // Phase 21 — resolve what (if anything) is being edited, and route a single
1775
+ // commit call to the right writer. `editingId` doubles as the host id
1776
+ // (anchored) OR the sticky/standalone stroke id; `pendingText` is the
1777
+ // not-yet-born text caret.
1778
+ const editingTarget = useMemo<EditingTarget>(() => {
1779
+ if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
1780
+ if (!editingId) return null;
1781
+ const host = anchorsById.get(editingId);
1782
+ if (host) return { kind: 'anchored', anchorId: editingId, host };
1783
+ const s = strokesById.get(editingId);
1784
+ if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
1785
+ if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
1786
+ return { kind: 'standalone', text: s };
1787
+ return null;
1788
+ }, [pendingText, editingId, anchorsById, strokesById]);
1789
+
1790
+ const editingTargetRef = useRef(editingTarget);
1791
+ editingTargetRef.current = editingTarget;
1792
+
1793
+ const commitEditing = useCallback(
1794
+ (text: string) => {
1795
+ const target = editingTargetRef.current;
1796
+ setEditingId(null);
1797
+ setPendingText(null);
1798
+ if (!target) return;
1799
+ if (target.kind === 'anchored') commitText(target.anchorId, text);
1800
+ else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text);
1801
+ else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text);
1802
+ else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text);
1803
+ },
1804
+ [commitText, commitStickyText, commitStandaloneText, createStandaloneText]
1805
+ );
1806
+
1807
+ const cancelEditing = useCallback(() => {
1808
+ setEditingId(null);
1809
+ setPendingText(null);
1810
+ }, []);
1811
+
1359
1812
  // Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
1360
1813
  useEffect(() => {
1361
1814
  if (typeof document === 'undefined') return;
@@ -1412,6 +1865,7 @@ export function AnnotationsLayer() {
1412
1865
  <AnnotationsInput
1413
1866
  isActive={isActive}
1414
1867
  visible={visible}
1868
+ cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
1415
1869
  beginStroke={beginStroke}
1416
1870
  moveStroke={moveStroke}
1417
1871
  endStroke={endStroke}
@@ -1424,25 +1878,20 @@ export function AnnotationsLayer() {
1424
1878
  selectMode={tool === 'move'}
1425
1879
  selectedStrokes={selectedStrokes}
1426
1880
  marquee={marquee}
1427
- editingId={editingId}
1428
- existingTextFor={(anchorId) =>
1429
- strokes.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
1430
- | TextStroke
1431
- | undefined
1432
- }
1433
- onCommitText={(anchorId, text) => {
1434
- commitText(anchorId, text);
1435
- setEditingId(null);
1436
- }}
1437
- onCancelEdit={() => setEditingId(null)}
1881
+ editingTarget={editingTarget}
1882
+ onCommitEdit={commitEditing}
1883
+ onCancelEdit={cancelEditing}
1438
1884
  />
1439
1885
  ) : null}
1440
1886
  <AnnotationContextToolbar />
1441
1887
  {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
1442
1888
  {isActive ? (
1443
1889
  <AnnotationsChrome
1890
+ tool={tool}
1444
1891
  color={color}
1445
1892
  setColor={setColor}
1893
+ stickyColor={stickyColor}
1894
+ setStickyColor={setStickyColor}
1446
1895
  supportsFill={supportsFill}
1447
1896
  fill={fill}
1448
1897
  setFill={setFill}
@@ -1466,12 +1915,16 @@ AnnotationsLayer.displayName = 'AnnotationsLayer';
1466
1915
  function AnnotationsInput({
1467
1916
  isActive,
1468
1917
  visible,
1918
+ cursor,
1469
1919
  beginStroke,
1470
1920
  moveStroke,
1471
1921
  endStroke,
1472
1922
  }: {
1473
1923
  isActive: boolean;
1474
1924
  visible: boolean;
1925
+ /** Active-tool cursor (crosshair / text / cell) — applied to the capture
1926
+ * overlay so the affordance shows over the whole canvas while drawing. */
1927
+ cursor: string;
1475
1928
  beginStroke: (e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => boolean;
1476
1929
  moveStroke: (e: ReactPointerEvent<HTMLDivElement>) => void;
1477
1930
  endStroke: () => void;
@@ -1509,7 +1962,10 @@ function AnnotationsInput({
1509
1962
  <div
1510
1963
  className="dc-annot-input"
1511
1964
  aria-hidden="true"
1512
- style={{ pointerEvents: interactive ? 'auto' : 'none' }}
1965
+ style={{
1966
+ pointerEvents: interactive ? 'auto' : 'none',
1967
+ cursor: interactive ? cursor : 'default',
1968
+ }}
1513
1969
  onPointerDown={(e) => {
1514
1970
  beginStroke(e, spaceHeldRef.current);
1515
1971
  }}
@@ -1534,9 +1990,8 @@ function AnnotationsSvg({
1534
1990
  selectMode,
1535
1991
  selectedStrokes,
1536
1992
  marquee,
1537
- editingId,
1538
- existingTextFor,
1539
- onCommitText,
1993
+ editingTarget,
1994
+ onCommitEdit,
1540
1995
  onCancelEdit,
1541
1996
  }: {
1542
1997
  worldRef: ReturnType<typeof useWorldRefContext>;
@@ -1545,9 +2000,8 @@ function AnnotationsSvg({
1545
2000
  selectMode: boolean;
1546
2001
  selectedStrokes: readonly Stroke[];
1547
2002
  marquee: { ax: number; ay: number; bx: number; by: number } | null;
1548
- editingId: string | null;
1549
- existingTextFor: (anchorId: string) => TextStroke | undefined;
1550
- onCommitText: (anchorId: string, text: string) => void;
2003
+ editingTarget: EditingTarget;
2004
+ onCommitEdit: (text: string) => void;
1551
2005
  onCancelEdit: () => void;
1552
2006
  }) {
1553
2007
  const [, force] = useState({});
@@ -1558,10 +2012,31 @@ function AnnotationsSvg({
1558
2012
  }, [worldRef]);
1559
2013
  const target = worldRef?.current ?? null;
1560
2014
  if (!target) return null;
2015
+ // A sticky whose body is being edited hides its read-only text so the
2016
+ // editor textarea (rendered below at the same bbox) isn't double-painted.
2017
+ const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
2018
+ const anchoredExisting =
2019
+ editingTarget?.kind === 'anchored'
2020
+ ? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
2021
+ | TextStroke
2022
+ | undefined)
2023
+ : undefined;
1561
2024
  return createPortal(
1562
2025
  <svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
2026
+ <defs>
2027
+ {/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
2028
+ <filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
2029
+ <feDropShadow dx="0" dy="4" stdDeviation="8" floodColor="#000000" floodOpacity="0.28" />
2030
+ </filter>
2031
+ </defs>
1563
2032
  {strokes.map((s) => (
1564
- <StrokeNode key={s.id} stroke={s} anchorsById={anchorsById} interactive={selectMode} />
2033
+ <StrokeNode
2034
+ key={s.id}
2035
+ stroke={s}
2036
+ anchorsById={anchorsById}
2037
+ interactive={selectMode}
2038
+ editing={s.id === editingStickyId}
2039
+ />
1565
2040
  ))}
1566
2041
  {selectedStrokes.map((s) => (
1567
2042
  <SelectionHalo
@@ -1582,12 +2057,41 @@ function AnnotationsSvg({
1582
2057
  vectorEffect="non-scaling-stroke"
1583
2058
  />
1584
2059
  ) : null}
1585
- {editingId ? (
2060
+ {editingTarget?.kind === 'anchored' ? (
1586
2061
  <TextEditor
1587
- anchorId={editingId}
1588
- host={anchorsById.get(editingId) ?? null}
1589
- existing={existingTextFor(editingId)}
1590
- onCommit={onCommitText}
2062
+ anchorId={editingTarget.anchorId}
2063
+ host={editingTarget.host}
2064
+ existing={anchoredExisting}
2065
+ onCommit={(_anchorId, text) => onCommitEdit(text)}
2066
+ onCancel={onCancelEdit}
2067
+ />
2068
+ ) : null}
2069
+ {editingTarget?.kind === 'sticky' ? (
2070
+ <StickyEditor
2071
+ sticky={editingTarget.sticky}
2072
+ onCommit={onCommitEdit}
2073
+ onCancel={onCancelEdit}
2074
+ />
2075
+ ) : null}
2076
+ {editingTarget?.kind === 'standalone' ? (
2077
+ <StandaloneTextEditor
2078
+ x={editingTarget.text.x ?? 0}
2079
+ y={editingTarget.text.y ?? 0}
2080
+ fontSize={editingTarget.text.fontSize}
2081
+ color={editingTarget.text.color}
2082
+ initialText={editingTarget.text.text}
2083
+ onCommit={onCommitEdit}
2084
+ onCancel={onCancelEdit}
2085
+ />
2086
+ ) : null}
2087
+ {editingTarget?.kind === 'pending' ? (
2088
+ <StandaloneTextEditor
2089
+ x={editingTarget.x}
2090
+ y={editingTarget.y}
2091
+ fontSize={DEFAULT_FONT_SIZE}
2092
+ color={DEFAULT_COLOR}
2093
+ initialText=""
2094
+ onCommit={onCommitEdit}
1591
2095
  onCancel={onCancelEdit}
1592
2096
  />
1593
2097
  ) : null}
@@ -1653,6 +2157,7 @@ function TextEditor({
1653
2157
  <foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
1654
2158
  <div
1655
2159
  ref={ref}
2160
+ className="dc-annot-editor"
1656
2161
  contentEditable
1657
2162
  suppressContentEditableWarning
1658
2163
  aria-label="Edit annotation text"
@@ -1692,6 +2197,154 @@ function TextEditor({
1692
2197
  );
1693
2198
  }
1694
2199
 
2200
+ // Phase 21 — sticky body editor. A textarea hosted in a foreignObject at the
2201
+ // card's bbox, so it word-wraps + moves with CSS zoom natively. Commit on blur,
2202
+ // cancel on Esc; Enter inserts a newline (sticky is multi-line).
2203
+ function StickyEditor({
2204
+ sticky,
2205
+ onCommit,
2206
+ onCancel,
2207
+ }: {
2208
+ sticky: StickyStroke;
2209
+ onCommit: (text: string) => void;
2210
+ onCancel: () => void;
2211
+ }) {
2212
+ // A flex-centered contentEditable (NOT a textarea) so the edit view matches
2213
+ // the committed `.dc-sticky-body` exactly — text stays centered, no jump on
2214
+ // commit. Multi-line: Enter inserts a line break; Esc cancels; blur commits.
2215
+ const ref = useRef<HTMLDivElement | null>(null);
2216
+ useEffect(() => {
2217
+ const el = ref.current;
2218
+ if (!el) return;
2219
+ el.focus();
2220
+ try {
2221
+ const r = document.createRange();
2222
+ r.selectNodeContents(el);
2223
+ const sel = window.getSelection();
2224
+ if (sel) {
2225
+ sel.removeAllRanges();
2226
+ sel.addRange(r);
2227
+ }
2228
+ } catch {
2229
+ /* selection API blocked */
2230
+ }
2231
+ }, []);
2232
+ const x = Math.min(sticky.x, sticky.x + sticky.w);
2233
+ const y = Math.min(sticky.y, sticky.y + sticky.h);
2234
+ const w = Math.abs(sticky.w);
2235
+ const h = Math.abs(sticky.h);
2236
+ return (
2237
+ <foreignObject x={x} y={y} width={w} height={h}>
2238
+ <div
2239
+ xmlns="http://www.w3.org/1999/xhtml"
2240
+ ref={ref}
2241
+ className="dc-annot-editor dc-sticky-body"
2242
+ contentEditable
2243
+ suppressContentEditableWarning
2244
+ aria-label="Edit sticky note text"
2245
+ style={{ fontSize: `${sticky.fontSize}px`, outline: 'none', cursor: 'text' }}
2246
+ onBlur={() => onCommit(ref.current?.innerText ?? '')}
2247
+ onKeyDown={(e) => {
2248
+ if (e.key === 'Escape') {
2249
+ e.preventDefault();
2250
+ onCancel();
2251
+ }
2252
+ }}
2253
+ >
2254
+ {sticky.text}
2255
+ </div>
2256
+ </foreignObject>
2257
+ );
2258
+ }
2259
+
2260
+ // Phase 21 — standalone text editor. A single-line contentEditable box anchored
2261
+ // at the world (x, y). Enter / blur / outside-click commit; Esc cancels.
2262
+ function StandaloneTextEditor({
2263
+ x,
2264
+ y,
2265
+ fontSize,
2266
+ color,
2267
+ initialText,
2268
+ onCommit,
2269
+ onCancel,
2270
+ }: {
2271
+ x: number;
2272
+ y: number;
2273
+ fontSize: number;
2274
+ color: string;
2275
+ initialText: string;
2276
+ onCommit: (text: string) => void;
2277
+ onCancel: () => void;
2278
+ }) {
2279
+ const ref = useRef<HTMLDivElement | null>(null);
2280
+ useEffect(() => {
2281
+ const el = ref.current;
2282
+ if (!el) return;
2283
+ el.focus();
2284
+ try {
2285
+ const r = document.createRange();
2286
+ r.selectNodeContents(el);
2287
+ const sel = window.getSelection();
2288
+ if (sel) {
2289
+ sel.removeAllRanges();
2290
+ sel.addRange(r);
2291
+ }
2292
+ } catch {
2293
+ /* selection API blocked */
2294
+ }
2295
+ }, []);
2296
+ // Commit on outside click.
2297
+ useEffect(() => {
2298
+ if (typeof document === 'undefined') return;
2299
+ const onDown = (e: PointerEvent) => {
2300
+ const el = ref.current;
2301
+ if (!el) return;
2302
+ if (el.contains(e.target as Node)) return;
2303
+ onCommit(el.innerText || '');
2304
+ };
2305
+ document.addEventListener('pointerdown', onDown, true);
2306
+ return () => document.removeEventListener('pointerdown', onDown, true);
2307
+ }, [onCommit]);
2308
+ return (
2309
+ <foreignObject x={x} y={y} width={600} height={Math.max(24, fontSize * 1.6)}>
2310
+ <div
2311
+ xmlns="http://www.w3.org/1999/xhtml"
2312
+ ref={ref}
2313
+ className="dc-annot-editor"
2314
+ contentEditable
2315
+ suppressContentEditableWarning
2316
+ aria-label="Edit text"
2317
+ style={{
2318
+ display: 'inline-block',
2319
+ minWidth: '8px',
2320
+ whiteSpace: 'pre',
2321
+ padding: '0 2px',
2322
+ color,
2323
+ fontSize: `${fontSize}px`,
2324
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
2325
+ lineHeight: 1.2,
2326
+ outline: 'none',
2327
+ background: 'transparent',
2328
+ cursor: 'text',
2329
+ }}
2330
+ onKeyDown={(e) => {
2331
+ if (e.key === 'Escape') {
2332
+ e.preventDefault();
2333
+ onCancel();
2334
+ return;
2335
+ }
2336
+ if (e.key === 'Enter' && !e.shiftKey) {
2337
+ e.preventDefault();
2338
+ onCommit(ref.current?.innerText || '');
2339
+ }
2340
+ }}
2341
+ >
2342
+ {initialText}
2343
+ </div>
2344
+ </foreignObject>
2345
+ );
2346
+ }
2347
+
1695
2348
  function SelectionHalo({
1696
2349
  stroke,
1697
2350
  anchorsById,
@@ -1792,7 +2445,7 @@ function AnnotGroupBbox({
1792
2445
  width={handle}
1793
2446
  height={handle}
1794
2447
  fill="var(--maude-hud-accent, #d63b1f)"
1795
- stroke="var(--bg-0, #ffffff)"
2448
+ stroke="var(--maude-chrome-bg-0, #ffffff)"
1796
2449
  strokeWidth={1}
1797
2450
  vectorEffect="non-scaling-stroke"
1798
2451
  rx={1}
@@ -1809,10 +2462,13 @@ function StrokeNode({
1809
2462
  stroke,
1810
2463
  anchorsById,
1811
2464
  interactive,
2465
+ editing = false,
1812
2466
  }: {
1813
2467
  stroke: Stroke;
1814
2468
  anchorsById: Map<string, RectStroke | EllipseStroke>;
1815
2469
  interactive: boolean;
2470
+ /** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
2471
+ editing?: boolean;
1816
2472
  }) {
1817
2473
  // In Move mode, individual stroke nodes claim pointer events so we can
1818
2474
  // hit-test them from the doc-level capture listener. In draw mode the
@@ -1820,29 +2476,89 @@ function StrokeNode({
1820
2476
  const hitMode = interactive ? 'visiblePainted' : ('none' as const);
1821
2477
  const strokeHit = interactive ? 'stroke' : ('none' as const);
1822
2478
  if (stroke.tool === 'text') {
1823
- const host = anchorsById.get(stroke.anchorId);
1824
- const bbox = host ? strokeBBox(host) : null;
1825
- if (!bbox) return null;
1826
- const cx = bbox.x + bbox.w / 2;
1827
- const cy = bbox.y + bbox.h / 2;
2479
+ // Anchored text renders centered in its host; standalone (Phase 21) renders
2480
+ // top-left-anchored at its own world (x, y).
2481
+ if (stroke.anchorId != null && stroke.anchorId !== '') {
2482
+ const host = anchorsById.get(stroke.anchorId);
2483
+ const bbox = host ? strokeBBox(host) : null;
2484
+ if (!bbox) return null;
2485
+ const cx = bbox.x + bbox.w / 2;
2486
+ const cy = bbox.y + bbox.h / 2;
2487
+ return (
2488
+ <text
2489
+ data-id={stroke.id}
2490
+ data-tool="text"
2491
+ data-anchor-id={stroke.anchorId}
2492
+ data-font-size={stroke.fontSize}
2493
+ x={cx}
2494
+ y={cy}
2495
+ fill={stroke.color}
2496
+ fontSize={stroke.fontSize}
2497
+ textAnchor="middle"
2498
+ dominantBaseline="middle"
2499
+ style={{
2500
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
2501
+ }}
2502
+ >
2503
+ {stroke.text}
2504
+ </text>
2505
+ );
2506
+ }
1828
2507
  return (
1829
2508
  <text
1830
2509
  data-id={stroke.id}
1831
2510
  data-tool="text"
1832
- data-anchor-id={stroke.anchorId}
1833
2511
  data-font-size={stroke.fontSize}
1834
- x={cx}
1835
- y={cy}
2512
+ x={stroke.x ?? 0}
2513
+ y={stroke.y ?? 0}
1836
2514
  fill={stroke.color}
1837
2515
  fontSize={stroke.fontSize}
1838
- textAnchor="middle"
1839
- dominantBaseline="middle"
2516
+ textAnchor="start"
2517
+ dominantBaseline="hanging"
2518
+ pointerEvents={interactive ? 'visiblePainted' : 'none'}
1840
2519
  style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
1841
2520
  >
1842
2521
  {stroke.text}
1843
2522
  </text>
1844
2523
  );
1845
2524
  }
2525
+ if (stroke.tool === 'sticky') {
2526
+ const x = Math.min(stroke.x, stroke.x + stroke.w);
2527
+ const y = Math.min(stroke.y, stroke.y + stroke.h);
2528
+ const w = Math.abs(stroke.w);
2529
+ const h = Math.abs(stroke.h);
2530
+ const r = stroke.cornerRadius ?? STICKY_CORNER_RADIUS;
2531
+ return (
2532
+ <g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
2533
+ {/* Paper card: soft drop shadow + hairline edge so it reads as a
2534
+ lifted sticky, not a flat colored box (FigJam-style). */}
2535
+ <rect
2536
+ x={x}
2537
+ y={y}
2538
+ width={w}
2539
+ height={h}
2540
+ rx={r}
2541
+ ry={r}
2542
+ fill={stroke.color}
2543
+ stroke="rgba(0,0,0,0.05)"
2544
+ strokeWidth={1}
2545
+ vectorEffect="non-scaling-stroke"
2546
+ filter="url(#dc-sticky-shadow)"
2547
+ />
2548
+ {editing ? null : (
2549
+ <foreignObject x={x} y={y} width={w} height={h} pointerEvents="none">
2550
+ <div
2551
+ xmlns="http://www.w3.org/1999/xhtml"
2552
+ className="dc-sticky-body"
2553
+ style={{ fontSize: `${stroke.fontSize}px` }}
2554
+ >
2555
+ {stroke.text}
2556
+ </div>
2557
+ </foreignObject>
2558
+ )}
2559
+ </g>
2560
+ );
2561
+ }
1846
2562
  const common = {
1847
2563
  'data-id': stroke.id,
1848
2564
  'data-tool': stroke.tool,
@@ -1858,6 +2574,7 @@ function StrokeNode({
1858
2574
  if (stroke.tool === 'rect') {
1859
2575
  const x = Math.min(stroke.x, stroke.x + stroke.w);
1860
2576
  const y = Math.min(stroke.y, stroke.y + stroke.h);
2577
+ const r = stroke.cornerRadius ?? 0;
1861
2578
  return (
1862
2579
  <rect
1863
2580
  {...common}
@@ -1866,6 +2583,8 @@ function StrokeNode({
1866
2583
  y={y}
1867
2584
  width={Math.abs(stroke.w)}
1868
2585
  height={Math.abs(stroke.h)}
2586
+ rx={r}
2587
+ ry={r}
1869
2588
  pointerEvents={hitMode}
1870
2589
  />
1871
2590
  );
@@ -1883,11 +2602,31 @@ function StrokeNode({
1883
2602
  />
1884
2603
  );
1885
2604
  }
1886
- const head = arrowHeadPoints(stroke.x1, stroke.y1, stroke.x2, stroke.y2, stroke.width);
2605
+ // arrow Phase 21 renders per-side heads + optional dash.
2606
+ const endHead = stroke.endHead ?? 'triangle';
2607
+ const startHead = stroke.startHead ?? 'none';
2608
+ const dashed = stroke.dashed ?? false;
1887
2609
  return (
1888
2610
  <g {...common} fill="none" pointerEvents={hitMode}>
1889
- <line x1={stroke.x1} y1={stroke.y1} x2={stroke.x2} y2={stroke.y2} />
1890
- <polyline points={head} fill={stroke.color} />
2611
+ <line
2612
+ x1={stroke.x1}
2613
+ y1={stroke.y1}
2614
+ x2={stroke.x2}
2615
+ y2={stroke.y2}
2616
+ strokeDasharray={dashed ? '6 4' : undefined}
2617
+ />
2618
+ {startHead === 'triangle' ? (
2619
+ <polyline
2620
+ points={arrowHeadPoints(stroke.x2, stroke.y2, stroke.x1, stroke.y1, stroke.width)}
2621
+ fill={stroke.color}
2622
+ />
2623
+ ) : null}
2624
+ {endHead === 'triangle' ? (
2625
+ <polyline
2626
+ points={arrowHeadPoints(stroke.x1, stroke.y1, stroke.x2, stroke.y2, stroke.width)}
2627
+ fill={stroke.color}
2628
+ />
2629
+ ) : null}
1891
2630
  </g>
1892
2631
  );
1893
2632
  }
@@ -1897,8 +2636,11 @@ function StrokeNode({
1897
2636
  // + presentation toggle + help button.
1898
2637
 
1899
2638
  function AnnotationsChrome({
2639
+ tool,
1900
2640
  color,
1901
2641
  setColor,
2642
+ stickyColor,
2643
+ setStickyColor,
1902
2644
  supportsFill,
1903
2645
  fill,
1904
2646
  setFill,
@@ -1906,8 +2648,11 @@ function AnnotationsChrome({
1906
2648
  thickness,
1907
2649
  setThickness,
1908
2650
  }: {
2651
+ tool: Tool;
1909
2652
  color: string;
1910
2653
  setColor: (c: string) => void;
2654
+ stickyColor: string;
2655
+ setStickyColor: (c: string) => void;
1911
2656
  supportsFill: boolean;
1912
2657
  fill: string | null;
1913
2658
  setFill: (f: string | null) => void;
@@ -1915,10 +2660,32 @@ function AnnotationsChrome({
1915
2660
  thickness: Thickness;
1916
2661
  setThickness: (t: Thickness) => void;
1917
2662
  }) {
2663
+ // Sticky tool picks a paper tint (its own palette); every other draw tool
2664
+ // picks ink from the stroke PALETTE.
2665
+ if (tool === 'sticky') {
2666
+ return (
2667
+ <div className="dc-annot-chrome" role="toolbar" aria-label="Sticky note tools">
2668
+ <div className="dc-annot-swatches" role="radiogroup" aria-label="Sticky color">
2669
+ {STICKY_PALETTE.map((c) => (
2670
+ <button
2671
+ key={c}
2672
+ type="button"
2673
+ className="dc-annot-sw"
2674
+ aria-pressed={c === stickyColor}
2675
+ aria-label={`Sticky color ${c}`}
2676
+ title={`Sticky color ${c}`}
2677
+ style={{ background: c }}
2678
+ onClick={() => setStickyColor(c)}
2679
+ />
2680
+ ))}
2681
+ </div>
2682
+ </div>
2683
+ );
2684
+ }
1918
2685
  return (
1919
2686
  <div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
1920
2687
  <div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
1921
- {PALETTE.map((c) => (
2688
+ {STROKE_PALETTE.map((c) => (
1922
2689
  <button
1923
2690
  key={c}
1924
2691
  type="button"
@@ -1963,21 +2730,23 @@ function AnnotationsChrome({
1963
2730
  <div className="dc-annot-sep" />
1964
2731
  <button
1965
2732
  type="button"
1966
- className="dc-annot-btn"
2733
+ className="dc-annot-ibtn"
2734
+ aria-label="Thin stroke"
1967
2735
  aria-pressed={thickness === STROKE_WIDTH_THIN}
1968
2736
  title="Thin (3px)"
1969
2737
  onClick={() => setThickness(STROKE_WIDTH_THIN)}
1970
2738
  >
1971
- Thin
2739
+ <IconLineThin />
1972
2740
  </button>
1973
2741
  <button
1974
2742
  type="button"
1975
- className="dc-annot-btn"
2743
+ className="dc-annot-ibtn"
2744
+ aria-label="Thick stroke"
1976
2745
  aria-pressed={thickness === STROKE_WIDTH_THICK}
1977
2746
  title="Thick (6px)"
1978
2747
  onClick={() => setThickness(STROKE_WIDTH_THICK)}
1979
2748
  >
1980
- Thick
2749
+ <IconLineThick />
1981
2750
  </button>
1982
2751
  </>
1983
2752
  ) : null}