@1agh/maude 0.22.2 → 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 (125) 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 +84 -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 +135 -10
  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 +222 -11
  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/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -22,129 +22,176 @@
22
22
 
23
23
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
24
 
25
- import { type Stroke, useStrokesStore } from './annotations-layer.tsx';
25
+ import {
26
+ FILL_PALETTE,
27
+ STROKE_PALETTE,
28
+ type Stroke,
29
+ useStrokesStore,
30
+ } from './annotations-layer.tsx';
31
+ import {
32
+ IconArrowBothHeads,
33
+ IconArrowEndHead,
34
+ IconArrowNone,
35
+ IconArrowStartHead,
36
+ IconCornerPill,
37
+ IconCornerSoft,
38
+ IconCornerSquare,
39
+ IconDash,
40
+ IconLetterA,
41
+ IconLineThick,
42
+ IconLineThin,
43
+ IconTrash,
44
+ } from './canvas-icons.tsx';
26
45
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
27
46
 
28
- // G6FigJam-style 11-color palette. Same colors for both stroke and fill
29
- // so the swatch row identity stays consistent when the user flips the
30
- // Stroke|Fill mode toggle (only the target attribute changes, not the
31
- // visual vocabulary).
32
- const PALETTE = [
33
- '#1E1E1E', // black
34
- '#757575', // gray
35
- '#E03E3E', // red
36
- '#F26B3F', // orange
37
- '#F2C744', // yellow
38
- '#4CAF50', // green
39
- '#14B8A6', // teal
40
- '#3B82F6', // blue
41
- '#8B5CF6', // purple
42
- '#EC4899', // pink
43
- '#FFFFFF', // white
44
- ] as const;
45
-
46
- const FILL_PALETTE = PALETTE;
47
+ // Phase 21 the swatch palettes come from annotations-layer so the draw-time
48
+ // chrome and this per-selection toolbar share ONE hue family. STROKE mode shows
49
+ // saturated inks; FILL mode shows the index-paired light tints (FigJam: a
50
+ // saturated outline over a pale wash of the same hue). They're referenced
51
+ // INSIDE the component (render time) — never at module top-level — because
52
+ // annotations-layer ↔ this file form an import cycle and a top-level read would
53
+ // hit the TDZ before STROKE_PALETTE initializes.
47
54
 
55
+ // Phase 21 — dark "property bar" matching FigJam's selection toolbar. A
56
+ // near-black rounded pill that floats above the selection; swatches + icons sit
57
+ // on dark so colour reads true. Fixed dark values (not canvas tokens) so the
58
+ // bar looks identical on any canvas / DS.
59
+ const CTX_SURFACE = '#26262b';
48
60
  const TOOLBAR_CSS = `
49
61
  .dc-annot-ctx {
50
62
  position: fixed;
51
63
  z-index: 7;
52
64
  display: flex;
53
65
  align-items: center;
54
- gap: 6px;
55
- background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
56
- border: 1px solid var(--u-fg-0, #1c1917);
57
- border-radius: 8px;
58
- padding: 6px 8px;
59
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
66
+ gap: 2px;
67
+ background: ${CTX_SURFACE};
68
+ border: 1px solid rgba(255,255,255,0.08);
69
+ border-radius: 12px;
70
+ padding: 5px 7px;
71
+ box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
60
72
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
61
73
  font-size: 12px;
62
- color: var(--u-fg-0, var(--fg-0, #1a1a1a));
74
+ color: rgba(255,255,255,0.85);
63
75
  user-select: none;
64
76
  pointer-events: auto;
65
77
  }
78
+ /* Swatches sit in a tight touching band so the colour row reads as ONE
79
+ control, not a loose ramp (graphic-critic blocker 1). 22px hit target. */
80
+ .dc-annot-ctx-swrow {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 1px;
84
+ }
66
85
  .dc-annot-ctx-sw {
67
- width: 16px;
68
- height: 16px;
69
- border-radius: 3px;
70
- border: 1px solid rgba(0,0,0,0.12);
86
+ width: 20px;
87
+ height: 20px;
88
+ border-radius: 50%;
89
+ border: 1px solid rgba(255,255,255,0.16);
71
90
  cursor: pointer;
72
91
  padding: 0;
92
+ margin: 0;
73
93
  appearance: none;
94
+ transition: transform 80ms ease;
74
95
  }
96
+ .dc-annot-ctx-sw:hover { transform: scale(1.1); }
75
97
  .dc-annot-ctx-sw[aria-pressed="true"] {
76
- box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
98
+ box-shadow: 0 0 0 2px ${CTX_SURFACE}, 0 0 0 3px rgba(255,255,255,0.92);
77
99
  border-color: transparent;
78
100
  }
101
+ .dc-annot-ctx-sw:focus-visible {
102
+ outline: 2px solid #ffffff;
103
+ outline-offset: 1px;
104
+ }
79
105
  .dc-annot-ctx-fill--none {
80
- background: #fff;
106
+ background: #3a3a40;
81
107
  position: relative;
82
108
  }
83
109
  .dc-annot-ctx-fill--none::after {
84
110
  content: "";
85
- position: absolute; inset: 2px;
111
+ position: absolute; inset: 4px;
112
+ border-radius: 50%;
86
113
  background:
87
- linear-gradient(135deg, transparent 47%, #d63b1f 47%, #d63b1f 53%, transparent 53%);
114
+ linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
88
115
  }
89
116
  .dc-annot-ctx-sep {
90
117
  width: 1px;
91
- align-self: stretch;
92
- background: rgba(0,0,0,0.10);
93
- margin: 0 2px;
94
- }
95
- .dc-annot-ctx-btn {
96
- appearance: none;
97
- background: transparent;
98
- border: 1px solid var(--u-fg-0, rgba(0,0,0,0.6));
99
- border-radius: 0;
100
- padding: 3px 8px;
101
- font: inherit;
102
- color: inherit;
103
- cursor: pointer;
104
- }
105
- .dc-annot-ctx-btn[aria-pressed="true"] {
106
- background: var(--maude-hud-accent, #d63b1f);
107
- color: var(--maude-hud-accent-fg, #fff);
108
- border-color: transparent;
118
+ height: 16px;
119
+ align-self: center;
120
+ background: rgba(255,255,255,0.09);
121
+ margin: 0 4px;
109
122
  }
110
- .dc-annot-ctx-btn:hover { background: rgba(0,0,0,0.04); }
111
- .dc-annot-ctx-btn--danger { color: #b3271a; }
112
- .dc-annot-ctx-btn--danger:hover { background: rgba(214,59,31,0.08); }
113
-
114
- /* T30 / G_S1 — collapsed Stroke|Fill mode toggle. Shown only when the
115
- selection supports both stroke + fill (rect / ellipse). When the toggle is
116
- present the swatch row beneath shows ONE palette at a time. */
123
+ /* Collapsed Stroke|Fill mode toggle (rect / ellipse selections). */
117
124
  .dc-annot-ctx-mode {
118
125
  display: inline-flex;
119
- border-radius: 6px;
126
+ border-radius: 7px;
120
127
  overflow: hidden;
121
- border: 1px solid rgba(0,0,0,0.18);
128
+ background: rgba(255,255,255,0.07);
129
+ padding: 2px;
130
+ gap: 2px;
122
131
  }
123
132
  .dc-annot-ctx-mode-btn {
124
133
  appearance: none;
125
134
  background: transparent;
126
135
  border: 0;
127
- padding: 3px 8px;
136
+ border-radius: 5px;
137
+ padding: 3px 9px;
128
138
  font: inherit;
129
- color: inherit;
139
+ color: rgba(255,255,255,0.6);
130
140
  cursor: pointer;
131
141
  font-size: 11px;
132
142
  letter-spacing: 0.02em;
133
- text-transform: uppercase;
134
143
  line-height: 1;
135
144
  }
136
145
  .dc-annot-ctx-mode-btn[aria-pressed="true"] {
137
- background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
138
- color: var(--maude-hud-accent, #d63b1f);
146
+ background: rgba(255,255,255,0.16);
147
+ color: #ffffff;
139
148
  font-weight: 600;
140
149
  }
141
150
  .dc-annot-ctx-mode-btn:not([aria-pressed="true"]):hover {
142
- background: rgba(0,0,0,0.04);
151
+ color: rgba(255,255,255,0.9);
152
+ }
153
+ .dc-annot-ctx-mode-btn:focus-visible {
154
+ outline: 2px solid #ffffff;
155
+ outline-offset: -1px;
156
+ }
157
+
158
+ /* Icon buttons — light glyph on dark, white-tint hover, white-tint active.
159
+ 26px to sit closer to the 20px swatch rhythm (graphic-critic blocker 2). */
160
+ .dc-annot-ctx-ibtn {
161
+ appearance: none;
162
+ background: transparent;
163
+ border: 0;
164
+ border-radius: 7px;
165
+ width: 26px;
166
+ height: 26px;
167
+ display: inline-flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ color: rgba(255,255,255,0.78);
171
+ cursor: pointer;
172
+ padding: 0;
173
+ position: relative;
174
+ transition: background-color 80ms linear, color 80ms linear;
175
+ }
176
+ .dc-annot-ctx-ibtn:hover {
177
+ background: rgba(255,255,255,0.1);
178
+ color: #ffffff;
179
+ }
180
+ .dc-annot-ctx-ibtn[aria-pressed="true"] {
181
+ background: rgba(255,255,255,0.18);
182
+ color: #ffffff;
183
+ }
184
+ .dc-annot-ctx-ibtn:focus-visible {
185
+ outline: 2px solid #ffffff;
186
+ outline-offset: -2px;
187
+ }
188
+ .dc-annot-ctx-ibtn--danger { color: rgba(255,255,255,0.7); }
189
+ .dc-annot-ctx-ibtn--danger:hover {
190
+ background: color-mix(in oklab, #ff5a4d 26%, transparent);
191
+ color: #ffffff;
143
192
  }
144
- /* Trailing overflow Delete — quiet by default, expressive on hover.
145
- Keyboard Backspace remains the primary delete affordance. */
146
- .dc-annot-ctx-overflow {
147
- margin-left: auto;
193
+ @media (prefers-reduced-motion: reduce) {
194
+ .dc-annot-ctx-ibtn, .dc-annot-ctx-sw { transition: none; }
148
195
  }
149
196
  `.trim();
150
197
 
@@ -202,19 +249,36 @@ export function AnnotationContextToolbar() {
202
249
  // Capabilities — intersection across selected types.
203
250
  const caps = useMemo(() => {
204
251
  if (selectedStrokes.length === 0) {
205
- return { color: false, fill: false, thickness: false, fontSize: false };
252
+ return {
253
+ color: false,
254
+ fill: false,
255
+ thickness: false,
256
+ fontSize: false,
257
+ cornerRadius: false,
258
+ arrowDir: false,
259
+ dash: false,
260
+ };
206
261
  }
207
262
  const allFillable = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'ellipse');
208
263
  // T20 — rect + ellipse now carry stroke weight too.
209
264
  const allThickness = selectedStrokes.every(
210
265
  (s) => s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
211
266
  );
212
- const anyText = selectedStrokes.some((s) => s.tool === 'text');
267
+ // Phase 21 fontSize applies to text + sticky; cornerRadius to rect +
268
+ // sticky; arrow direction + dash to arrows.
269
+ const fontSizeApplicable = selectedStrokes.some(
270
+ (s) => s.tool === 'text' || s.tool === 'sticky'
271
+ );
272
+ const allRectOrSticky = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'sticky');
273
+ const allArrow = selectedStrokes.every((s) => s.tool === 'arrow');
213
274
  return {
214
275
  color: true,
215
276
  fill: allFillable,
216
277
  thickness: allThickness,
217
- fontSize: anyText,
278
+ fontSize: fontSizeApplicable,
279
+ cornerRadius: allRectOrSticky,
280
+ arrowDir: allArrow,
281
+ dash: allArrow,
218
282
  };
219
283
  }, [selectedStrokes]);
220
284
 
@@ -299,13 +363,50 @@ export function AnnotationContextToolbar() {
299
363
  (sz: number) => {
300
364
  if (!store) return;
301
365
  for (const s of selectedStrokes) {
302
- if (s.tool === 'text') {
366
+ // Phase 21 sticky carries fontSize too.
367
+ if (s.tool === 'text' || s.tool === 'sticky') {
303
368
  store.updateStroke(s.id, { fontSize: sz } as Partial<Stroke>);
304
369
  }
305
370
  }
306
371
  },
307
372
  [store, selectedStrokes]
308
373
  );
374
+ // Phase 21 — corner radius (rect + sticky).
375
+ const setCornerRadius = useCallback(
376
+ (r: number) => {
377
+ if (!store) return;
378
+ for (const s of selectedStrokes) {
379
+ if (s.tool === 'rect' || s.tool === 'sticky') {
380
+ store.updateStroke(s.id, { cornerRadius: r } as Partial<Stroke>);
381
+ }
382
+ }
383
+ },
384
+ [store, selectedStrokes]
385
+ );
386
+ // Phase 21 — arrow head direction (None / Start / End / Both).
387
+ const setArrowDir = useCallback(
388
+ (startHead: 'none' | 'triangle', endHead: 'none' | 'triangle') => {
389
+ if (!store) return;
390
+ for (const s of selectedStrokes) {
391
+ if (s.tool === 'arrow') {
392
+ store.updateStroke(s.id, { startHead, endHead } as Partial<Stroke>);
393
+ }
394
+ }
395
+ },
396
+ [store, selectedStrokes]
397
+ );
398
+ // Phase 21 — arrow dash toggle.
399
+ const setDashed = useCallback(
400
+ (dashed: boolean) => {
401
+ if (!store) return;
402
+ for (const s of selectedStrokes) {
403
+ if (s.tool === 'arrow') {
404
+ store.updateStroke(s.id, { dashed } as Partial<Stroke>);
405
+ }
406
+ }
407
+ },
408
+ [store, selectedStrokes]
409
+ );
309
410
  const remove = useCallback(() => {
310
411
  if (!annotSel || !store) return;
311
412
  store.deleteStrokes(annotSel.selectedIds);
@@ -333,14 +434,47 @@ export function AnnotationContextToolbar() {
333
434
  )
334
435
  : undefined;
335
436
  const uniqFontSize = caps.fontSize
336
- ? uniformValue(selectedStrokes.map((s) => (s.tool === 'text' ? s.fontSize : undefined)))
437
+ ? uniformValue(
438
+ selectedStrokes.map((s) =>
439
+ s.tool === 'text' || s.tool === 'sticky' ? s.fontSize : undefined
440
+ )
441
+ )
442
+ : undefined;
443
+ // Phase 21 — uniform corner radius across rect/sticky (default per type: rect
444
+ // sharp = 0, sticky soft = 8).
445
+ const uniqRadius = caps.cornerRadius
446
+ ? uniformValue(
447
+ selectedStrokes.map((s) =>
448
+ s.tool === 'rect'
449
+ ? (s.cornerRadius ?? 0)
450
+ : s.tool === 'sticky'
451
+ ? (s.cornerRadius ?? 8)
452
+ : undefined
453
+ )
454
+ )
455
+ : undefined;
456
+ // Phase 21 — uniform arrow head pair (default start none / end triangle).
457
+ const uniqStartHead = caps.arrowDir
458
+ ? uniformValue(
459
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.startHead ?? 'none') : undefined))
460
+ )
461
+ : undefined;
462
+ const uniqEndHead = caps.arrowDir
463
+ ? uniformValue(
464
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.endHead ?? 'triangle') : undefined))
465
+ )
466
+ : undefined;
467
+ const uniqDashed = caps.dash
468
+ ? uniformValue(
469
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.dashed ?? false) : undefined))
470
+ )
337
471
  : undefined;
338
472
 
339
473
  // T30 / G_S1 — when caps.fill is false we never enter fill mode. The
340
474
  // useEffect below could call setSwatchMode('stroke') but reading the
341
475
  // effective mode inline avoids an extra render cycle.
342
476
  const effectiveMode: SwatchMode = caps.fill ? swatchMode : 'stroke';
343
- const showPalette = effectiveMode === 'stroke' ? PALETTE : FILL_PALETTE;
477
+ const showPalette = effectiveMode === 'stroke' ? STROKE_PALETTE : FILL_PALETTE;
344
478
  const onSwatchClick =
345
479
  effectiveMode === 'stroke' ? (c: string) => setColor(c) : (c: string) => setFill(c);
346
480
  const activeValue = effectiveMode === 'stroke' ? uniqColor : (uniqFill ?? null);
@@ -376,48 +510,52 @@ export function AnnotationContextToolbar() {
376
510
  <div className="dc-annot-ctx-sep" />
377
511
  </>
378
512
  ) : null}
379
- {effectiveMode === 'fill' ? (
380
- <button
381
- type="button"
382
- className="dc-annot-ctx-sw dc-annot-ctx-fill--none"
383
- aria-label="No fill"
384
- aria-pressed={uniqFill == null}
385
- title="No fill"
386
- onClick={() => setFill(null)}
387
- />
388
- ) : null}
389
- {showPalette.map((c) => (
390
- <button
391
- key={c}
392
- type="button"
393
- className="dc-annot-ctx-sw"
394
- aria-label={`${effectiveMode === 'stroke' ? 'Color' : 'Fill'} ${c}`}
395
- aria-pressed={activeValue === c}
396
- title={`${effectiveMode === 'stroke' ? 'Color' : 'Fill'} ${c}`}
397
- style={{ background: c }}
398
- onClick={() => onSwatchClick(c)}
399
- />
400
- ))}
513
+ <div className="dc-annot-ctx-swrow" role="radiogroup" aria-label="Color">
514
+ {effectiveMode === 'fill' ? (
515
+ <button
516
+ type="button"
517
+ className="dc-annot-ctx-sw dc-annot-ctx-fill--none"
518
+ aria-label="No fill"
519
+ aria-pressed={uniqFill == null}
520
+ title="No fill"
521
+ onClick={() => setFill(null)}
522
+ />
523
+ ) : null}
524
+ {showPalette.map((c) => (
525
+ <button
526
+ key={c}
527
+ type="button"
528
+ className="dc-annot-ctx-sw"
529
+ aria-label={`${effectiveMode === 'stroke' ? 'Color' : 'Fill'} ${c}`}
530
+ aria-pressed={activeValue === c}
531
+ title={`${effectiveMode === 'stroke' ? 'Color' : 'Fill'} ${c}`}
532
+ style={{ background: c }}
533
+ onClick={() => onSwatchClick(c)}
534
+ />
535
+ ))}
536
+ </div>
401
537
  {caps.thickness && effectiveMode === 'stroke' ? (
402
538
  <>
403
539
  <div className="dc-annot-ctx-sep" />
404
540
  <button
405
541
  type="button"
406
- className="dc-annot-ctx-btn"
542
+ className="dc-annot-ctx-ibtn"
543
+ aria-label="Thin stroke"
407
544
  aria-pressed={uniqThickness === 3}
408
545
  title="Thin (3px)"
409
546
  onClick={() => setThickness(3)}
410
547
  >
411
- Thin
548
+ <IconLineThin />
412
549
  </button>
413
550
  <button
414
551
  type="button"
415
- className="dc-annot-ctx-btn"
552
+ className="dc-annot-ctx-ibtn"
553
+ aria-label="Thick stroke"
416
554
  aria-pressed={uniqThickness === 6}
417
555
  title="Thick (6px)"
418
556
  onClick={() => setThickness(6)}
419
557
  >
420
- Thick
558
+ <IconLineThick />
421
559
  </button>
422
560
  </>
423
561
  ) : null}
@@ -426,41 +564,140 @@ export function AnnotationContextToolbar() {
426
564
  <div className="dc-annot-ctx-sep" />
427
565
  <button
428
566
  type="button"
429
- className="dc-annot-ctx-btn"
567
+ className="dc-annot-ctx-ibtn"
568
+ aria-label="Small text"
430
569
  aria-pressed={uniqFontSize === 12}
431
570
  title="Small (12px)"
432
571
  onClick={() => setFontSize(12)}
433
572
  >
434
- S
573
+ <IconLetterA size={13} />
435
574
  </button>
436
575
  <button
437
576
  type="button"
438
- className="dc-annot-ctx-btn"
577
+ className="dc-annot-ctx-ibtn"
578
+ aria-label="Medium text"
439
579
  aria-pressed={uniqFontSize === 14}
440
580
  title="Medium (14px)"
441
581
  onClick={() => setFontSize(14)}
442
582
  >
443
- M
583
+ <IconLetterA size={16} />
444
584
  </button>
445
585
  <button
446
586
  type="button"
447
- className="dc-annot-ctx-btn"
587
+ className="dc-annot-ctx-ibtn"
588
+ aria-label="Large text"
448
589
  aria-pressed={uniqFontSize === 20}
449
590
  title="Large (20px)"
450
591
  onClick={() => setFontSize(20)}
451
592
  >
452
- L
593
+ <IconLetterA size={19} />
594
+ </button>
595
+ </>
596
+ ) : null}
597
+ {caps.cornerRadius ? (
598
+ <>
599
+ <div className="dc-annot-ctx-sep" />
600
+ <button
601
+ type="button"
602
+ className="dc-annot-ctx-ibtn"
603
+ aria-label="Square corners"
604
+ aria-pressed={uniqRadius === 0}
605
+ title="Square corners"
606
+ onClick={() => setCornerRadius(0)}
607
+ >
608
+ <IconCornerSquare />
609
+ </button>
610
+ <button
611
+ type="button"
612
+ className="dc-annot-ctx-ibtn"
613
+ aria-label="Soft corners"
614
+ aria-pressed={uniqRadius === 8}
615
+ title="Soft corners"
616
+ onClick={() => setCornerRadius(8)}
617
+ >
618
+ <IconCornerSoft />
619
+ </button>
620
+ <button
621
+ type="button"
622
+ className="dc-annot-ctx-ibtn"
623
+ aria-label="Pill corners"
624
+ aria-pressed={uniqRadius === 999}
625
+ title="Pill corners"
626
+ onClick={() => setCornerRadius(999)}
627
+ >
628
+ <IconCornerPill />
629
+ </button>
630
+ </>
631
+ ) : null}
632
+ {caps.arrowDir ? (
633
+ <>
634
+ <div className="dc-annot-ctx-sep" />
635
+ <button
636
+ type="button"
637
+ className="dc-annot-ctx-ibtn"
638
+ aria-label="Line, no arrowheads"
639
+ aria-pressed={uniqStartHead === 'none' && uniqEndHead === 'none'}
640
+ title="Line (no heads)"
641
+ onClick={() => setArrowDir('none', 'none')}
642
+ >
643
+ <IconArrowNone />
644
+ </button>
645
+ <button
646
+ type="button"
647
+ className="dc-annot-ctx-ibtn"
648
+ aria-label="Arrowhead at start"
649
+ aria-pressed={uniqStartHead === 'triangle' && uniqEndHead === 'none'}
650
+ title="Head at start"
651
+ onClick={() => setArrowDir('triangle', 'none')}
652
+ >
653
+ <IconArrowStartHead />
654
+ </button>
655
+ <button
656
+ type="button"
657
+ className="dc-annot-ctx-ibtn"
658
+ aria-label="Arrowhead at end"
659
+ aria-pressed={uniqStartHead === 'none' && uniqEndHead === 'triangle'}
660
+ title="Head at end"
661
+ onClick={() => setArrowDir('none', 'triangle')}
662
+ >
663
+ <IconArrowEndHead />
664
+ </button>
665
+ <button
666
+ type="button"
667
+ className="dc-annot-ctx-ibtn"
668
+ aria-label="Arrowheads at both ends"
669
+ aria-pressed={uniqStartHead === 'triangle' && uniqEndHead === 'triangle'}
670
+ title="Heads at both ends"
671
+ onClick={() => setArrowDir('triangle', 'triangle')}
672
+ >
673
+ <IconArrowBothHeads />
674
+ </button>
675
+ </>
676
+ ) : null}
677
+ {caps.dash ? (
678
+ <>
679
+ <div className="dc-annot-ctx-sep" />
680
+ <button
681
+ type="button"
682
+ className="dc-annot-ctx-ibtn"
683
+ aria-label="Dashed line"
684
+ aria-pressed={uniqDashed === true}
685
+ title="Dashed line"
686
+ onClick={() => setDashed(!(uniqDashed === true))}
687
+ >
688
+ <IconDash />
453
689
  </button>
454
690
  </>
455
691
  ) : null}
692
+ <div className="dc-annot-ctx-sep" />
456
693
  <button
457
694
  type="button"
458
- className="dc-annot-ctx-btn dc-annot-ctx-btn--danger dc-annot-ctx-overflow"
695
+ className="dc-annot-ctx-ibtn dc-annot-ctx-ibtn--danger"
459
696
  title="Delete (Backspace)"
460
697
  aria-label="Delete selected annotations"
461
698
  onClick={remove}
462
699
  >
463
- Delete
700
+ <IconTrash />
464
701
  </button>
465
702
  </div>
466
703
  );