@1agh/maude 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -20,131 +20,326 @@
20
20
  * - font-size: only when at least one stroke is text (and the rest are its host)
21
21
  */
22
22
 
23
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
24
 
25
- import { type Stroke, useStrokesStore } from './annotations-layer.tsx';
25
+ import {
26
+ type ArrowHead,
27
+ type ArrowLineType,
28
+ FILL_PALETTE,
29
+ STICKY_PALETTE,
30
+ STROKE_PALETTE,
31
+ type Stroke,
32
+ type TextAlign,
33
+ useStrokesStore,
34
+ } from './annotations-layer.tsx';
35
+ import {
36
+ IconAlignCenter,
37
+ IconAlignLeft,
38
+ IconAlignRight,
39
+ IconArrowNone,
40
+ IconArrowheadCircle,
41
+ IconArrowheadDiamond,
42
+ IconArrowheadLine,
43
+ IconArrowheadTriangle,
44
+ IconArrowheadTriangleOutline,
45
+ IconBold,
46
+ IconChevronDown,
47
+ IconCornerPill,
48
+ IconCornerSoft,
49
+ IconCornerSquare,
50
+ IconDash,
51
+ IconLineCurved,
52
+ IconLineElbow,
53
+ IconLineStraight,
54
+ IconLineThick,
55
+ IconLineThin,
56
+ IconStrike,
57
+ IconTrash,
58
+ } from './canvas-icons.tsx';
26
59
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
27
60
 
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;
61
+ // Phase 24 arrowhead + line-type option metadata (icon + label per value),
62
+ // driving the per-end head dropdowns + the line-type dropdown.
63
+ const HEAD_ICON: Record<ArrowHead, (p: { size?: number }) => ReactNode> = {
64
+ none: IconArrowNone,
65
+ line: IconArrowheadLine,
66
+ triangle: IconArrowheadTriangle,
67
+ 'triangle-outline': IconArrowheadTriangleOutline,
68
+ circle: IconArrowheadCircle,
69
+ diamond: IconArrowheadDiamond,
70
+ };
71
+ const HEAD_OPTIONS: ReadonlyArray<{ value: ArrowHead; label: string }> = [
72
+ { value: 'none', label: 'None' },
73
+ { value: 'line', label: 'Line' },
74
+ { value: 'triangle', label: 'Triangle' },
75
+ { value: 'triangle-outline', label: 'Triangle (outline)' },
76
+ { value: 'circle', label: 'Circle' },
77
+ { value: 'diamond', label: 'Diamond' },
78
+ ];
79
+ const LINETYPE_ICON: Record<ArrowLineType, (p: { size?: number }) => ReactNode> = {
80
+ straight: IconLineStraight,
81
+ curved: IconLineCurved,
82
+ elbow: IconLineElbow,
83
+ };
84
+ const LINETYPE_OPTIONS: ReadonlyArray<{ value: ArrowLineType; label: string }> = [
85
+ { value: 'straight', label: 'Straight' },
86
+ { value: 'curved', label: 'Curved' },
87
+ { value: 'elbow', label: 'Elbow' },
88
+ ];
89
+ // Phase 24 — text alignment (text + sticky) + named font-size presets.
90
+ const ALIGN_ICON: Record<TextAlign, (p: { size?: number }) => ReactNode> = {
91
+ left: IconAlignLeft,
92
+ center: IconAlignCenter,
93
+ right: IconAlignRight,
94
+ };
95
+ const ALIGN_OPTIONS: ReadonlyArray<{ value: TextAlign; label: string }> = [
96
+ { value: 'left', label: 'Align left' },
97
+ { value: 'center', label: 'Align center' },
98
+ { value: 'right', label: 'Align right' },
99
+ ];
100
+ const FONT_SIZE_PRESETS: ReadonlyArray<{ px: number; label: string }> = [
101
+ { px: 12, label: 'Small' },
102
+ { px: 16, label: 'Medium' },
103
+ { px: 24, label: 'Large' },
104
+ { px: 36, label: 'Extra large' },
105
+ { px: 64, label: 'Huge' },
106
+ ];
107
+ const FONT_SIZE_MIN = 8;
108
+ const FONT_SIZE_MAX = 200;
45
109
 
46
- const FILL_PALETTE = PALETTE;
110
+ // Phase 21 — the swatch palettes come from annotations-layer so the draw-time
111
+ // chrome and this per-selection toolbar share ONE hue family. STROKE mode shows
112
+ // saturated inks; FILL mode shows the index-paired light tints (FigJam: a
113
+ // saturated outline over a pale wash of the same hue). They're referenced
114
+ // INSIDE the component (render time) — never at module top-level — because
115
+ // annotations-layer ↔ this file form an import cycle and a top-level read would
116
+ // hit the TDZ before STROKE_PALETTE initializes.
47
117
 
118
+ // Phase 21 — dark "property bar" matching FigJam's selection toolbar. A
119
+ // near-black rounded pill that floats above the selection; swatches + icons sit
120
+ // on dark so colour reads true. Fixed dark values (not canvas tokens) so the
121
+ // bar looks identical on any canvas / DS.
122
+ const CTX_SURFACE = '#26262b';
48
123
  const TOOLBAR_CSS = `
49
124
  .dc-annot-ctx {
50
125
  position: fixed;
51
126
  z-index: 7;
52
127
  display: flex;
53
128
  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);
129
+ gap: 2px;
130
+ background: ${CTX_SURFACE};
131
+ border: 1px solid rgba(255,255,255,0.08);
132
+ border-radius: 12px;
133
+ padding: 5px 7px;
134
+ box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
60
135
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
61
136
  font-size: 12px;
62
- color: var(--u-fg-0, var(--fg-0, #1a1a1a));
137
+ color: rgba(255,255,255,0.85);
63
138
  user-select: none;
64
139
  pointer-events: auto;
65
140
  }
141
+ /* Swatches sit in a tight touching band so the colour row reads as ONE
142
+ control, not a loose ramp (graphic-critic blocker 1). 22px hit target. */
143
+ .dc-annot-ctx-swrow {
144
+ display: inline-flex;
145
+ align-items: center;
146
+ gap: 1px;
147
+ }
66
148
  .dc-annot-ctx-sw {
67
- width: 16px;
68
- height: 16px;
69
- border-radius: 3px;
70
- border: 1px solid rgba(0,0,0,0.12);
149
+ width: 20px;
150
+ height: 20px;
151
+ border-radius: 50%;
152
+ border: 1px solid rgba(255,255,255,0.16);
71
153
  cursor: pointer;
72
154
  padding: 0;
155
+ margin: 0;
73
156
  appearance: none;
157
+ transition: transform 80ms ease;
74
158
  }
159
+ .dc-annot-ctx-sw:hover { transform: scale(1.1); }
75
160
  .dc-annot-ctx-sw[aria-pressed="true"] {
76
- box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
161
+ box-shadow: 0 0 0 2px ${CTX_SURFACE}, 0 0 0 3px rgba(255,255,255,0.92);
77
162
  border-color: transparent;
78
163
  }
164
+ .dc-annot-ctx-sw:focus-visible {
165
+ outline: 2px solid #ffffff;
166
+ outline-offset: 1px;
167
+ }
79
168
  .dc-annot-ctx-fill--none {
80
- background: #fff;
169
+ background: #3a3a40;
81
170
  position: relative;
82
171
  }
83
172
  .dc-annot-ctx-fill--none::after {
84
173
  content: "";
85
- position: absolute; inset: 2px;
174
+ position: absolute; inset: 4px;
175
+ border-radius: 50%;
86
176
  background:
87
- linear-gradient(135deg, transparent 47%, #d63b1f 47%, #d63b1f 53%, transparent 53%);
177
+ linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
88
178
  }
89
179
  .dc-annot-ctx-sep {
90
180
  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;
181
+ height: 16px;
182
+ align-self: center;
183
+ background: rgba(255,255,255,0.09);
184
+ margin: 0 4px;
109
185
  }
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. */
186
+ /* Collapsed Stroke|Fill mode toggle (rect / ellipse selections). */
117
187
  .dc-annot-ctx-mode {
118
188
  display: inline-flex;
119
- border-radius: 6px;
189
+ border-radius: 7px;
120
190
  overflow: hidden;
121
- border: 1px solid rgba(0,0,0,0.18);
191
+ background: rgba(255,255,255,0.07);
192
+ padding: 2px;
193
+ gap: 2px;
122
194
  }
123
195
  .dc-annot-ctx-mode-btn {
124
196
  appearance: none;
125
197
  background: transparent;
126
198
  border: 0;
127
- padding: 3px 8px;
199
+ border-radius: 5px;
200
+ padding: 3px 9px;
128
201
  font: inherit;
129
- color: inherit;
202
+ color: rgba(255,255,255,0.6);
130
203
  cursor: pointer;
131
204
  font-size: 11px;
132
205
  letter-spacing: 0.02em;
133
- text-transform: uppercase;
134
206
  line-height: 1;
135
207
  }
136
208
  .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);
209
+ background: rgba(255,255,255,0.16);
210
+ color: #ffffff;
139
211
  font-weight: 600;
140
212
  }
141
213
  .dc-annot-ctx-mode-btn:not([aria-pressed="true"]):hover {
142
- background: rgba(0,0,0,0.04);
214
+ color: rgba(255,255,255,0.9);
215
+ }
216
+ .dc-annot-ctx-mode-btn:focus-visible {
217
+ outline: 2px solid #ffffff;
218
+ outline-offset: -1px;
219
+ }
220
+
221
+ /* Icon buttons — light glyph on dark, white-tint hover, white-tint active.
222
+ 26px to sit closer to the 20px swatch rhythm (graphic-critic blocker 2). */
223
+ .dc-annot-ctx-ibtn {
224
+ appearance: none;
225
+ background: transparent;
226
+ border: 0;
227
+ border-radius: 7px;
228
+ width: 26px;
229
+ height: 26px;
230
+ display: inline-flex;
231
+ align-items: center;
232
+ justify-content: center;
233
+ color: rgba(255,255,255,0.78);
234
+ cursor: pointer;
235
+ padding: 0;
236
+ position: relative;
237
+ transition: background-color 80ms linear, color 80ms linear;
238
+ }
239
+ .dc-annot-ctx-ibtn:hover {
240
+ background: rgba(255,255,255,0.1);
241
+ color: #ffffff;
143
242
  }
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;
243
+ .dc-annot-ctx-ibtn[aria-pressed="true"] {
244
+ background: rgba(255,255,255,0.18);
245
+ color: #ffffff;
246
+ }
247
+ .dc-annot-ctx-ibtn:focus-visible {
248
+ outline: 2px solid #ffffff;
249
+ outline-offset: -2px;
250
+ }
251
+ .dc-annot-ctx-ibtn--danger { color: rgba(255,255,255,0.7); }
252
+ .dc-annot-ctx-ibtn--danger:hover {
253
+ background: color-mix(in oklab, #ff5a4d 26%, transparent);
254
+ color: #ffffff;
255
+ }
256
+ /* Phase 24 — icon dropdown (arrowhead / line-type / text-align). The wrapper
257
+ anchors the menu directly above the trigger. */
258
+ .dc-annot-ctx-dd { position: relative; display: inline-flex; }
259
+ .dc-annot-ctx-dd-trigger { padding-right: 9px; }
260
+ .dc-annot-ctx-dd-caret {
261
+ position: absolute;
262
+ right: 1px;
263
+ bottom: 2px;
264
+ display: inline-flex;
265
+ opacity: 0.5;
266
+ pointer-events: none;
267
+ }
268
+ .dc-annot-ctx-menu {
269
+ position: absolute;
270
+ bottom: calc(100% + 6px);
271
+ left: 50%;
272
+ transform: translateX(-50%);
273
+ display: flex;
274
+ gap: 1px;
275
+ padding: 4px;
276
+ background: ${CTX_SURFACE};
277
+ border: 1px solid rgba(255,255,255,0.1);
278
+ border-radius: 9px;
279
+ box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.24);
280
+ z-index: 9;
281
+ }
282
+ .dc-annot-ctx-menu button[aria-checked="true"] {
283
+ background: rgba(255,255,255,0.18);
284
+ color: #ffffff;
285
+ }
286
+ /* Font-size dropdown — wider trigger showing the current px + a vertical menu
287
+ of named presets and a numeric input. */
288
+ .dc-annot-ctx-fs-trigger {
289
+ width: auto;
290
+ min-width: 30px;
291
+ padding: 0 14px 0 7px;
292
+ font-variant-numeric: tabular-nums;
293
+ }
294
+ .dc-annot-ctx-fs-val { font-size: 12px; }
295
+ .dc-annot-ctx-fs-menu {
296
+ position: absolute;
297
+ bottom: calc(100% + 6px);
298
+ left: 50%;
299
+ transform: translateX(-50%);
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 1px;
303
+ padding: 4px;
304
+ min-width: 132px;
305
+ background: ${CTX_SURFACE};
306
+ border: 1px solid rgba(255,255,255,0.1);
307
+ border-radius: 9px;
308
+ box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.24);
309
+ z-index: 9;
310
+ }
311
+ .dc-annot-ctx-fs-item {
312
+ appearance: none;
313
+ background: transparent;
314
+ border: 0;
315
+ border-radius: 6px;
316
+ padding: 5px 8px;
317
+ display: flex;
318
+ justify-content: space-between;
319
+ gap: 16px;
320
+ font: inherit;
321
+ font-size: 12px;
322
+ color: rgba(255,255,255,0.85);
323
+ cursor: pointer;
324
+ }
325
+ .dc-annot-ctx-fs-item:hover { background: rgba(255,255,255,0.1); }
326
+ .dc-annot-ctx-fs-item[aria-checked="true"] { background: rgba(255,255,255,0.18); color: #fff; }
327
+ .dc-annot-ctx-fs-px { opacity: 0.5; font-variant-numeric: tabular-nums; }
328
+ .dc-annot-ctx-fs-input {
329
+ margin-top: 3px;
330
+ width: 100%;
331
+ box-sizing: border-box;
332
+ background: rgba(255,255,255,0.08);
333
+ border: 1px solid rgba(255,255,255,0.14);
334
+ border-radius: 6px;
335
+ padding: 4px 8px;
336
+ color: #fff;
337
+ font: inherit;
338
+ font-size: 12px;
339
+ }
340
+ .dc-annot-ctx-fs-input:focus-visible { outline: 2px solid #fff; outline-offset: -1px; }
341
+ @media (prefers-reduced-motion: reduce) {
342
+ .dc-annot-ctx-ibtn, .dc-annot-ctx-sw { transition: none; }
148
343
  }
149
344
  `.trim();
150
345
 
@@ -202,19 +397,39 @@ export function AnnotationContextToolbar() {
202
397
  // Capabilities — intersection across selected types.
203
398
  const caps = useMemo(() => {
204
399
  if (selectedStrokes.length === 0) {
205
- return { color: false, fill: false, thickness: false, fontSize: false };
400
+ return {
401
+ color: false,
402
+ fill: false,
403
+ thickness: false,
404
+ fontSize: false,
405
+ cornerRadius: false,
406
+ arrowDir: false,
407
+ dash: false,
408
+ };
206
409
  }
207
410
  const allFillable = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'ellipse');
208
411
  // T20 — rect + ellipse now carry stroke weight too.
209
412
  const allThickness = selectedStrokes.every(
210
413
  (s) => s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
211
414
  );
212
- const anyText = selectedStrokes.some((s) => s.tool === 'text');
415
+ // Phase 21 fontSize applies to text + sticky; cornerRadius to rect +
416
+ // sticky; arrow direction + dash to arrows.
417
+ const fontSizeApplicable = selectedStrokes.some(
418
+ (s) => s.tool === 'text' || s.tool === 'sticky'
419
+ );
420
+ // Phase 24 — the corner-radius control is rect-only now. Stickies have a
421
+ // fixed soft radius (no switch); the sticky-color swatch row is handled
422
+ // separately below.
423
+ const allRect = selectedStrokes.every((s) => s.tool === 'rect');
424
+ const allArrow = selectedStrokes.every((s) => s.tool === 'arrow');
213
425
  return {
214
426
  color: true,
215
427
  fill: allFillable,
216
428
  thickness: allThickness,
217
- fontSize: anyText,
429
+ fontSize: fontSizeApplicable,
430
+ cornerRadius: allRect,
431
+ arrowDir: allArrow,
432
+ dash: allArrow,
218
433
  };
219
434
  }, [selectedStrokes]);
220
435
 
@@ -299,13 +514,101 @@ export function AnnotationContextToolbar() {
299
514
  (sz: number) => {
300
515
  if (!store) return;
301
516
  for (const s of selectedStrokes) {
302
- if (s.tool === 'text') {
517
+ // Phase 21 sticky carries fontSize too.
518
+ if (s.tool === 'text' || s.tool === 'sticky') {
303
519
  store.updateStroke(s.id, { fontSize: sz } as Partial<Stroke>);
304
520
  }
305
521
  }
306
522
  },
307
523
  [store, selectedStrokes]
308
524
  );
525
+ // Phase 24 — bold / strike / align on text + sticky bodies.
526
+ const setBold = useCallback(
527
+ (bold: boolean) => {
528
+ if (!store) return;
529
+ for (const s of selectedStrokes) {
530
+ if (s.tool === 'text' || s.tool === 'sticky') {
531
+ store.updateStroke(s.id, { bold } as Partial<Stroke>);
532
+ }
533
+ }
534
+ },
535
+ [store, selectedStrokes]
536
+ );
537
+ const setStrike = useCallback(
538
+ (strike: boolean) => {
539
+ if (!store) return;
540
+ for (const s of selectedStrokes) {
541
+ if (s.tool === 'text' || s.tool === 'sticky') {
542
+ store.updateStroke(s.id, { strike } as Partial<Stroke>);
543
+ }
544
+ }
545
+ },
546
+ [store, selectedStrokes]
547
+ );
548
+ const setAlign = useCallback(
549
+ (align: TextAlign) => {
550
+ if (!store) return;
551
+ for (const s of selectedStrokes) {
552
+ if (s.tool === 'text' || s.tool === 'sticky') {
553
+ store.updateStroke(s.id, { align } as Partial<Stroke>);
554
+ }
555
+ }
556
+ },
557
+ [store, selectedStrokes]
558
+ );
559
+ // Phase 21 — corner radius (rect + sticky).
560
+ const setCornerRadius = useCallback(
561
+ (r: number) => {
562
+ if (!store) return;
563
+ for (const s of selectedStrokes) {
564
+ if (s.tool === 'rect' || s.tool === 'sticky') {
565
+ store.updateStroke(s.id, { cornerRadius: r } as Partial<Stroke>);
566
+ }
567
+ }
568
+ },
569
+ [store, selectedStrokes]
570
+ );
571
+ // Phase 24 — per-end arrowhead + line-type (replaces the 4 fixed direction
572
+ // presets with two head dropdowns + a routing dropdown).
573
+ const setStartHead = useCallback(
574
+ (startHead: ArrowHead) => {
575
+ if (!store) return;
576
+ for (const s of selectedStrokes) {
577
+ if (s.tool === 'arrow') store.updateStroke(s.id, { startHead } as Partial<Stroke>);
578
+ }
579
+ },
580
+ [store, selectedStrokes]
581
+ );
582
+ const setEndHead = useCallback(
583
+ (endHead: ArrowHead) => {
584
+ if (!store) return;
585
+ for (const s of selectedStrokes) {
586
+ if (s.tool === 'arrow') store.updateStroke(s.id, { endHead } as Partial<Stroke>);
587
+ }
588
+ },
589
+ [store, selectedStrokes]
590
+ );
591
+ const setLineType = useCallback(
592
+ (lineType: ArrowLineType) => {
593
+ if (!store) return;
594
+ for (const s of selectedStrokes) {
595
+ if (s.tool === 'arrow') store.updateStroke(s.id, { lineType } as Partial<Stroke>);
596
+ }
597
+ },
598
+ [store, selectedStrokes]
599
+ );
600
+ // Phase 21 — arrow dash toggle.
601
+ const setDashed = useCallback(
602
+ (dashed: boolean) => {
603
+ if (!store) return;
604
+ for (const s of selectedStrokes) {
605
+ if (s.tool === 'arrow') {
606
+ store.updateStroke(s.id, { dashed } as Partial<Stroke>);
607
+ }
608
+ }
609
+ },
610
+ [store, selectedStrokes]
611
+ );
309
612
  const remove = useCallback(() => {
310
613
  if (!annotSel || !store) return;
311
614
  store.deleteStrokes(annotSel.selectedIds);
@@ -333,17 +636,92 @@ export function AnnotationContextToolbar() {
333
636
  )
334
637
  : undefined;
335
638
  const uniqFontSize = caps.fontSize
336
- ? uniformValue(selectedStrokes.map((s) => (s.tool === 'text' ? s.fontSize : undefined)))
639
+ ? uniformValue(
640
+ selectedStrokes.map((s) =>
641
+ s.tool === 'text' || s.tool === 'sticky' ? s.fontSize : undefined
642
+ )
643
+ )
644
+ : undefined;
645
+ // Phase 24 — uniform bold / strike / align across the text-bearing selection.
646
+ // Align default differs per kind: anchored text = centre, standalone + sticky
647
+ // = left (matches the serializer defaults so the active-state reads true).
648
+ const uniqBold = caps.fontSize
649
+ ? uniformValue(
650
+ selectedStrokes.map((s) =>
651
+ s.tool === 'text' || s.tool === 'sticky' ? !!s.bold : undefined
652
+ )
653
+ )
654
+ : undefined;
655
+ const uniqStrike = caps.fontSize
656
+ ? uniformValue(
657
+ selectedStrokes.map((s) =>
658
+ s.tool === 'text' || s.tool === 'sticky' ? !!s.strike : undefined
659
+ )
660
+ )
661
+ : undefined;
662
+ const uniqAlign = caps.fontSize
663
+ ? uniformValue(
664
+ selectedStrokes.map((s) => {
665
+ if (s.tool === 'sticky') return s.align ?? 'left';
666
+ if (s.tool === 'text') return s.align ?? (s.anchorId ? 'center' : 'left');
667
+ return undefined;
668
+ })
669
+ )
670
+ : undefined;
671
+ // Phase 21 — uniform corner radius across rect/sticky (default per type: rect
672
+ // sharp = 0, sticky soft = 8).
673
+ const uniqRadius = caps.cornerRadius
674
+ ? uniformValue(
675
+ selectedStrokes.map((s) =>
676
+ s.tool === 'rect'
677
+ ? (s.cornerRadius ?? 0)
678
+ : s.tool === 'sticky'
679
+ ? (s.cornerRadius ?? 8)
680
+ : undefined
681
+ )
682
+ )
683
+ : undefined;
684
+ // Phase 21 — uniform arrow head pair (default start none / end triangle).
685
+ const uniqStartHead = caps.arrowDir
686
+ ? uniformValue(
687
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.startHead ?? 'none') : undefined))
688
+ )
689
+ : undefined;
690
+ const uniqEndHead = caps.arrowDir
691
+ ? uniformValue(
692
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.endHead ?? 'triangle') : undefined))
693
+ )
694
+ : undefined;
695
+ const uniqLineType = caps.arrowDir
696
+ ? uniformValue(
697
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.lineType ?? 'straight') : undefined))
698
+ )
699
+ : undefined;
700
+ const uniqDashed = caps.dash
701
+ ? uniformValue(
702
+ selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.dashed ?? false) : undefined))
703
+ )
337
704
  : undefined;
338
705
 
339
706
  // T30 / G_S1 — when caps.fill is false we never enter fill mode. The
340
707
  // useEffect below could call setSwatchMode('stroke') but reading the
341
708
  // effective mode inline avoids an extra render cycle.
342
709
  const effectiveMode: SwatchMode = caps.fill ? swatchMode : 'stroke';
343
- const showPalette = effectiveMode === 'stroke' ? PALETTE : FILL_PALETTE;
710
+ // Phase 24 a sticky selection picks its PAPER TINT, not stroke ink. The
711
+ // pre-Phase-24 toolbar showed STROKE_PALETTE for stickies (a latent bug):
712
+ // sticky has no stroke, so its swatch must paint the muted STICKY_PALETTE and
713
+ // write to `color` (the paper tint). No Stroke|Fill toggle for sticky
714
+ // (caps.fill is already false).
715
+ const allSticky = selectedStrokes.every((s) => s.tool === 'sticky');
716
+ const showPalette = allSticky
717
+ ? STICKY_PALETTE
718
+ : effectiveMode === 'stroke'
719
+ ? STROKE_PALETTE
720
+ : FILL_PALETTE;
344
721
  const onSwatchClick =
345
- effectiveMode === 'stroke' ? (c: string) => setColor(c) : (c: string) => setFill(c);
346
- const activeValue = effectiveMode === 'stroke' ? uniqColor : (uniqFill ?? null);
722
+ !allSticky && effectiveMode === 'fill' ? (c: string) => setFill(c) : (c: string) => setColor(c);
723
+ const activeValue = allSticky || effectiveMode === 'stroke' ? uniqColor : (uniqFill ?? null);
724
+ const swatchKind = allSticky ? 'Sticky color' : effectiveMode === 'stroke' ? 'Color' : 'Fill';
347
725
 
348
726
  return (
349
727
  <div
@@ -376,97 +754,359 @@ export function AnnotationContextToolbar() {
376
754
  <div className="dc-annot-ctx-sep" />
377
755
  </>
378
756
  ) : 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
- ))}
757
+ <div className="dc-annot-ctx-swrow" role="radiogroup" aria-label="Color">
758
+ {effectiveMode === 'fill' ? (
759
+ <button
760
+ type="button"
761
+ className="dc-annot-ctx-sw dc-annot-ctx-fill--none"
762
+ aria-label="No fill"
763
+ aria-pressed={uniqFill == null}
764
+ title="No fill"
765
+ onClick={() => setFill(null)}
766
+ />
767
+ ) : null}
768
+ {showPalette.map((c) => (
769
+ <button
770
+ key={c}
771
+ type="button"
772
+ className="dc-annot-ctx-sw"
773
+ aria-label={`${swatchKind} ${c}`}
774
+ aria-pressed={activeValue === c}
775
+ title={`${swatchKind} ${c}`}
776
+ style={{ background: c }}
777
+ onClick={() => onSwatchClick(c)}
778
+ />
779
+ ))}
780
+ </div>
401
781
  {caps.thickness && effectiveMode === 'stroke' ? (
402
782
  <>
403
783
  <div className="dc-annot-ctx-sep" />
404
784
  <button
405
785
  type="button"
406
- className="dc-annot-ctx-btn"
786
+ className="dc-annot-ctx-ibtn"
787
+ aria-label="Thin stroke"
407
788
  aria-pressed={uniqThickness === 3}
408
789
  title="Thin (3px)"
409
790
  onClick={() => setThickness(3)}
410
791
  >
411
- Thin
792
+ <IconLineThin />
412
793
  </button>
413
794
  <button
414
795
  type="button"
415
- className="dc-annot-ctx-btn"
796
+ className="dc-annot-ctx-ibtn"
797
+ aria-label="Thick stroke"
416
798
  aria-pressed={uniqThickness === 6}
417
799
  title="Thick (6px)"
418
800
  onClick={() => setThickness(6)}
419
801
  >
420
- Thick
802
+ <IconLineThick />
421
803
  </button>
422
804
  </>
423
805
  ) : null}
424
806
  {caps.fontSize ? (
425
807
  <>
426
808
  <div className="dc-annot-ctx-sep" />
809
+ <FontSizeDropdown value={uniqFontSize} onPick={setFontSize} />
810
+ <button
811
+ type="button"
812
+ className="dc-annot-ctx-ibtn"
813
+ aria-label="Bold"
814
+ aria-pressed={uniqBold === true}
815
+ title="Bold"
816
+ onClick={() => setBold(!(uniqBold === true))}
817
+ >
818
+ <IconBold />
819
+ </button>
820
+ <button
821
+ type="button"
822
+ className="dc-annot-ctx-ibtn"
823
+ aria-label="Strikethrough"
824
+ aria-pressed={uniqStrike === true}
825
+ title="Strikethrough"
826
+ onClick={() => setStrike(!(uniqStrike === true))}
827
+ >
828
+ <IconStrike />
829
+ </button>
830
+ <IconDropdown
831
+ ariaLabel="Text alignment"
832
+ value={uniqAlign ?? 'left'}
833
+ options={ALIGN_OPTIONS}
834
+ renderIcon={(v) => {
835
+ const I = ALIGN_ICON[v as TextAlign];
836
+ return I ? <I size={16} /> : null;
837
+ }}
838
+ onPick={(v) => setAlign(v as TextAlign)}
839
+ />
840
+ </>
841
+ ) : null}
842
+ {caps.cornerRadius ? (
843
+ <>
844
+ <div className="dc-annot-ctx-sep" />
845
+ <button
846
+ type="button"
847
+ className="dc-annot-ctx-ibtn"
848
+ aria-label="Square corners"
849
+ aria-pressed={uniqRadius === 0}
850
+ title="Square corners"
851
+ onClick={() => setCornerRadius(0)}
852
+ >
853
+ <IconCornerSquare />
854
+ </button>
427
855
  <button
428
856
  type="button"
429
- className="dc-annot-ctx-btn"
430
- aria-pressed={uniqFontSize === 12}
431
- title="Small (12px)"
432
- onClick={() => setFontSize(12)}
857
+ className="dc-annot-ctx-ibtn"
858
+ aria-label="Soft corners"
859
+ aria-pressed={uniqRadius === 8}
860
+ title="Soft corners"
861
+ onClick={() => setCornerRadius(8)}
433
862
  >
434
- S
863
+ <IconCornerSoft />
435
864
  </button>
436
865
  <button
437
866
  type="button"
438
- className="dc-annot-ctx-btn"
439
- aria-pressed={uniqFontSize === 14}
440
- title="Medium (14px)"
441
- onClick={() => setFontSize(14)}
867
+ className="dc-annot-ctx-ibtn"
868
+ aria-label="Pill corners"
869
+ aria-pressed={uniqRadius === 999}
870
+ title="Pill corners"
871
+ onClick={() => setCornerRadius(999)}
442
872
  >
443
- M
873
+ <IconCornerPill />
444
874
  </button>
875
+ </>
876
+ ) : null}
877
+ {caps.arrowDir ? (
878
+ <>
879
+ <div className="dc-annot-ctx-sep" />
880
+ <IconDropdown
881
+ ariaLabel="Start arrowhead"
882
+ value={uniqStartHead ?? 'none'}
883
+ options={HEAD_OPTIONS}
884
+ renderIcon={(v) => {
885
+ const I = HEAD_ICON[v as ArrowHead];
886
+ return I ? <I size={16} /> : null;
887
+ }}
888
+ onPick={(v) => setStartHead(v as ArrowHead)}
889
+ />
890
+ <IconDropdown
891
+ ariaLabel="End arrowhead"
892
+ value={uniqEndHead ?? 'triangle'}
893
+ options={HEAD_OPTIONS}
894
+ renderIcon={(v) => {
895
+ const I = HEAD_ICON[v as ArrowHead];
896
+ return I ? <I size={16} /> : null;
897
+ }}
898
+ onPick={(v) => setEndHead(v as ArrowHead)}
899
+ />
900
+ <IconDropdown
901
+ ariaLabel="Line type"
902
+ value={uniqLineType ?? 'straight'}
903
+ options={LINETYPE_OPTIONS}
904
+ renderIcon={(v) => {
905
+ const I = LINETYPE_ICON[v as ArrowLineType];
906
+ return I ? <I size={16} /> : null;
907
+ }}
908
+ onPick={(v) => setLineType(v as ArrowLineType)}
909
+ />
910
+ </>
911
+ ) : null}
912
+ {caps.dash ? (
913
+ <>
914
+ <div className="dc-annot-ctx-sep" />
445
915
  <button
446
916
  type="button"
447
- className="dc-annot-ctx-btn"
448
- aria-pressed={uniqFontSize === 20}
449
- title="Large (20px)"
450
- onClick={() => setFontSize(20)}
917
+ className="dc-annot-ctx-ibtn"
918
+ aria-label="Dashed line"
919
+ aria-pressed={uniqDashed === true}
920
+ title="Dashed line"
921
+ onClick={() => setDashed(!(uniqDashed === true))}
451
922
  >
452
- L
923
+ <IconDash />
453
924
  </button>
454
925
  </>
455
926
  ) : null}
927
+ <div className="dc-annot-ctx-sep" />
456
928
  <button
457
929
  type="button"
458
- className="dc-annot-ctx-btn dc-annot-ctx-btn--danger dc-annot-ctx-overflow"
930
+ className="dc-annot-ctx-ibtn dc-annot-ctx-ibtn--danger"
459
931
  title="Delete (Backspace)"
460
932
  aria-label="Delete selected annotations"
461
933
  onClick={remove}
462
934
  >
463
- Delete
935
+ <IconTrash />
464
936
  </button>
465
937
  </div>
466
938
  );
467
939
  }
468
940
  AnnotationContextToolbar.displayName = 'AnnotationContextToolbar';
469
941
 
942
+ /**
943
+ * Phase 24 — a compact icon dropdown for the context toolbar (arrowhead per
944
+ * end, line-type). Trigger shows the current value's icon + a caret; the menu
945
+ * is a single row of icon-only radio items. Closes on outside-pointerdown or
946
+ * Escape. Reuses the existing `.dc-annot-ctx-ibtn` button styling.
947
+ */
948
+ function IconDropdown({
949
+ ariaLabel,
950
+ value,
951
+ options,
952
+ renderIcon,
953
+ onPick,
954
+ }: {
955
+ ariaLabel: string;
956
+ value: string;
957
+ options: ReadonlyArray<{ value: string; label: string }>;
958
+ renderIcon: (v: string) => ReactNode;
959
+ onPick: (v: string) => void;
960
+ }) {
961
+ const [open, setOpen] = useState(false);
962
+ const ref = useRef<HTMLDivElement | null>(null);
963
+ useEffect(() => {
964
+ if (!open) return;
965
+ const onDown = (e: PointerEvent) => {
966
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
967
+ };
968
+ const onKey = (e: KeyboardEvent) => {
969
+ if (e.key === 'Escape') setOpen(false);
970
+ };
971
+ document.addEventListener('pointerdown', onDown, true);
972
+ document.addEventListener('keydown', onKey, true);
973
+ return () => {
974
+ document.removeEventListener('pointerdown', onDown, true);
975
+ document.removeEventListener('keydown', onKey, true);
976
+ };
977
+ }, [open]);
978
+ const current = options.find((o) => o.value === value);
979
+ return (
980
+ <div ref={ref} className="dc-annot-ctx-dd">
981
+ <button
982
+ type="button"
983
+ className="dc-annot-ctx-ibtn dc-annot-ctx-dd-trigger"
984
+ aria-haspopup="menu"
985
+ aria-expanded={open}
986
+ aria-label={`${ariaLabel}: ${current?.label ?? value}`}
987
+ title={ariaLabel}
988
+ onClick={() => setOpen((o) => !o)}
989
+ >
990
+ {renderIcon(value)}
991
+ <span className="dc-annot-ctx-dd-caret" aria-hidden="true">
992
+ <IconChevronDown size={8} />
993
+ </span>
994
+ </button>
995
+ {open ? (
996
+ <div className="dc-annot-ctx-menu" role="menu" aria-label={ariaLabel}>
997
+ {options.map((o) => (
998
+ <button
999
+ key={o.value}
1000
+ type="button"
1001
+ role="menuitemradio"
1002
+ aria-checked={o.value === value}
1003
+ aria-label={o.label}
1004
+ title={o.label}
1005
+ className="dc-annot-ctx-ibtn"
1006
+ onClick={() => {
1007
+ onPick(o.value);
1008
+ setOpen(false);
1009
+ }}
1010
+ >
1011
+ {renderIcon(o.value)}
1012
+ </button>
1013
+ ))}
1014
+ </div>
1015
+ ) : null}
1016
+ </div>
1017
+ );
1018
+ }
1019
+
1020
+ /**
1021
+ * Phase 24 — font-size control: named presets (Small → Huge) + a numeric input
1022
+ * for an arbitrary px value (clamped 8–200, applied on Enter / blur). The
1023
+ * trigger shows the current size, or "—" when the selection is mixed.
1024
+ */
1025
+ function FontSizeDropdown({
1026
+ value,
1027
+ onPick,
1028
+ }: {
1029
+ value: number | undefined;
1030
+ onPick: (n: number) => void;
1031
+ }) {
1032
+ const [open, setOpen] = useState(false);
1033
+ const ref = useRef<HTMLDivElement | null>(null);
1034
+ useEffect(() => {
1035
+ if (!open) return;
1036
+ const onDown = (e: PointerEvent) => {
1037
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
1038
+ };
1039
+ const onKey = (e: KeyboardEvent) => {
1040
+ if (e.key === 'Escape') setOpen(false);
1041
+ };
1042
+ document.addEventListener('pointerdown', onDown, true);
1043
+ document.addEventListener('keydown', onKey, true);
1044
+ return () => {
1045
+ document.removeEventListener('pointerdown', onDown, true);
1046
+ document.removeEventListener('keydown', onKey, true);
1047
+ };
1048
+ }, [open]);
1049
+ const applyInput = (raw: string) => {
1050
+ const n = Math.round(Number(raw));
1051
+ if (Number.isFinite(n) && n >= FONT_SIZE_MIN && n <= FONT_SIZE_MAX) onPick(n);
1052
+ };
1053
+ return (
1054
+ <div ref={ref} className="dc-annot-ctx-dd">
1055
+ <button
1056
+ type="button"
1057
+ className="dc-annot-ctx-ibtn dc-annot-ctx-fs-trigger"
1058
+ aria-haspopup="menu"
1059
+ aria-expanded={open}
1060
+ aria-label={`Font size: ${value != null ? `${value} pixels` : 'mixed'}`}
1061
+ title="Font size"
1062
+ onClick={() => setOpen((o) => !o)}
1063
+ >
1064
+ <span className="dc-annot-ctx-fs-val">{value != null ? value : '—'}</span>
1065
+ <span className="dc-annot-ctx-dd-caret" aria-hidden="true">
1066
+ <IconChevronDown size={8} />
1067
+ </span>
1068
+ </button>
1069
+ {open ? (
1070
+ <div className="dc-annot-ctx-fs-menu" role="menu" aria-label="Font size">
1071
+ {FONT_SIZE_PRESETS.map((p) => (
1072
+ <button
1073
+ key={p.px}
1074
+ type="button"
1075
+ role="menuitemradio"
1076
+ aria-checked={value === p.px}
1077
+ className="dc-annot-ctx-fs-item"
1078
+ onClick={() => {
1079
+ onPick(p.px);
1080
+ setOpen(false);
1081
+ }}
1082
+ >
1083
+ <span>{p.label}</span>
1084
+ <span className="dc-annot-ctx-fs-px">{p.px}</span>
1085
+ </button>
1086
+ ))}
1087
+ <input
1088
+ key={value ?? 'mixed'}
1089
+ className="dc-annot-ctx-fs-input"
1090
+ type="number"
1091
+ min={FONT_SIZE_MIN}
1092
+ max={FONT_SIZE_MAX}
1093
+ defaultValue={value ?? ''}
1094
+ aria-label="Custom font size in pixels"
1095
+ onKeyDown={(e) => {
1096
+ if (e.key === 'Enter') {
1097
+ e.preventDefault();
1098
+ applyInput((e.target as HTMLInputElement).value);
1099
+ setOpen(false);
1100
+ }
1101
+ }}
1102
+ onBlur={(e) => applyInput(e.target.value)}
1103
+ />
1104
+ </div>
1105
+ ) : null}
1106
+ </div>
1107
+ );
1108
+ }
1109
+
470
1110
  function uniformValue<T>(values: (T | undefined)[]): T | undefined {
471
1111
  const filtered = values.filter((v) => v !== undefined) as T[];
472
1112
  if (filtered.length === 0) return undefined;