@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,2077 @@
1
+ /**
2
+ * @file annotations-model.ts — React-free annotation data model
3
+ * @scope apps/studio/annotations-model.ts
4
+ * @purpose Single source for the annotation Stroke types, palettes, pure
5
+ * geometry helpers, and the SVG serialize/parse pair. Extracted
6
+ * from annotations-layer.tsx (FigJam v3) so headless consumers —
7
+ * bun tests, the `maude design annotate` write verb, future
8
+ * tooling — can import the model without pulling React or any
9
+ * canvas runtime. The layer re-exports everything from here, so
10
+ * every existing `from './annotations-layer.tsx'` import keeps
11
+ * working unchanged.
12
+ *
13
+ * HARD INVARIANT (Phase 24 canary): `strokeToSvgEl` emits every
14
+ * post-Phase-5.1 attribute ONLY for non-default values, so legacy
15
+ * `.annotations.svg` files round-trip byte-identically. The
16
+ * FigJam-v3 additions (`data-group-ids`, `data-author`,
17
+ * `data-start-bind`, `data-end-bind`) follow the same rule.
18
+ *
19
+ * Schema (back-compatible with Phase 5):
20
+ * - pen → <path data-tool="pen" d="M.. L..">
21
+ * - rect → <rect data-tool="rect" x= y= width= height= [fill=]>
22
+ * - ellipse → <ellipse data-tool="ellipse" cx= cy= rx= ry= [fill=]>
23
+ * - arrow → <g data-tool="arrow"><line/><polyline/></g>
24
+ * - text → <text data-tool="text" data-anchor-id= x= y= fill= …>
25
+ * - sticky / polygon / image / link — see the per-tool serializers below.
26
+ */
27
+
28
+ import {
29
+ ARROW_HEADS,
30
+ type ArrowHead,
31
+ type ArrowLineType,
32
+ arrowPrimitives,
33
+ type SvgPrimitive,
34
+ } from './canvas-arrowheads.ts';
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // Types
38
+
39
+ export type WorldPoint = readonly [number, number];
40
+
41
+ /** Phase 24 — polygon shape primitives (diamond + the two triangle pointings). */
42
+ export type PolygonShape = 'diamond' | 'triangle' | 'triangle-down';
43
+ /** Phase 24 — horizontal alignment for text + sticky bodies. */
44
+ export type TextAlign = 'left' | 'center' | 'right';
45
+ /** Annotation polish (item 4c) — list style for text + sticky bodies. */
46
+ export type ListType = 'bullet' | 'number';
47
+
48
+ /**
49
+ * FigJam v3 — a bound arrow endpoint. `hostId` is the bound stroke's id;
50
+ * `nx`/`ny` are the anchor normalized over the host's bbox ([0..1], snapped to
51
+ * the {0, 0.5, 1} side/center magnets at bind time — FigJam connector magnets,
52
+ * Excalidraw `fixedPoint` semantics). The endpoint's world position is always
53
+ * DERIVED from the host (`anchorPoint` in annotations-bindings.ts); the stored
54
+ * x1/y1/x2/y2 are kept in sync so legacy consumers keep working.
55
+ */
56
+ export interface ArrowBind {
57
+ hostId: string;
58
+ nx: number;
59
+ ny: number;
60
+ /**
61
+ * FigJam v3 — true when the user EXPLICITLY re-anchored this endpoint (drag
62
+ * onto a specific magnet). Auto (absent) binds re-route as the shapes move:
63
+ * `recomputeBoundArrows` re-picks the side facing the other end, so a
64
+ * connector always leaves the box in a sensible direction. Serialized as a
65
+ * trailing ` p` token.
66
+ */
67
+ pinned?: boolean;
68
+ }
69
+
70
+ /**
71
+ * FigJam v3 — fields shared by every stroke variant.
72
+ * - `groupIds` — group membership, ordered DEEPEST → SHALLOWEST (the
73
+ * Excalidraw flat tag-array model; the outermost group is the LAST
74
+ * element). Absent / empty = ungrouped. Serialized as `data-group-ids`.
75
+ * - `author` — provenance. `'ai'` when created through the
76
+ * `maude design annotate` write surface. Absent = human-drawn.
77
+ */
78
+ interface StrokeBase {
79
+ id: string;
80
+ groupIds?: string[];
81
+ author?: 'ai';
82
+ /**
83
+ * FigJam v3 — rotation in degrees (clockwise) around the stroke's bbox
84
+ * center. Absent / 0 = axis-aligned (back-compat). Honoured by the box-
85
+ * shaped strokes + standalone text; pen ink and arrows ignore it (their
86
+ * geometry IS their orientation) and anchored text inherits its host's.
87
+ * Serialized as `data-rot` plus a presentational `transform="rotate(…)"`.
88
+ */
89
+ rotation?: number;
90
+ }
91
+
92
+ export interface PenStroke extends StrokeBase {
93
+ tool: 'pen';
94
+ color: string;
95
+ width: number;
96
+ points: WorldPoint[];
97
+ /**
98
+ * Highlighter (item 8). A `highlighter:true` pen reuses ALL pen draw / erase /
99
+ * hit-test / translate logic; it just renders wide + translucent with
100
+ * `mix-blend-mode:multiply` (overlaps darken) and carries a translucent
101
+ * marker colour. Absent / false = a normal solid pen (back-compat).
102
+ */
103
+ highlighter?: boolean;
104
+ }
105
+ export interface RectStroke extends StrokeBase {
106
+ tool: 'rect';
107
+ color: string;
108
+ width: number;
109
+ x: number;
110
+ y: number;
111
+ w: number;
112
+ h: number;
113
+ fill?: string | null;
114
+ /** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
115
+ cornerRadius?: number;
116
+ /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
117
+ dashed?: boolean;
118
+ }
119
+ export interface EllipseStroke extends StrokeBase {
120
+ tool: 'ellipse';
121
+ color: string;
122
+ width: number;
123
+ cx: number;
124
+ cy: number;
125
+ rx: number;
126
+ ry: number;
127
+ fill?: string | null;
128
+ /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
129
+ dashed?: boolean;
130
+ }
131
+ /**
132
+ * Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox
133
+ * (x/y/w/h, exactly like a rect) + a `shape` discriminant; the actual SVG
134
+ * points are derived from the bbox at serialize + render time. Brand-new on
135
+ * disk (`<polygon data-tool="polygon" data-shape="…">`), so no back-compat
136
+ * constraint — only idempotent round-trip.
137
+ */
138
+ export interface PolygonStroke extends StrokeBase {
139
+ tool: 'polygon';
140
+ shape: PolygonShape;
141
+ color: string;
142
+ width: number;
143
+ x: number;
144
+ y: number;
145
+ w: number;
146
+ h: number;
147
+ fill?: string | null;
148
+ /** Dashed outline (stroke-dasharray). Absent / false = solid. */
149
+ dashed?: boolean;
150
+ }
151
+ export interface ArrowStroke extends StrokeBase {
152
+ tool: 'arrow';
153
+ color: string;
154
+ width: number;
155
+ x1: number;
156
+ y1: number;
157
+ x2: number;
158
+ y2: number;
159
+ /** Head on the (x1,y1) start. Absent = 'none' (back-compat). Phase 24 widened the enum. */
160
+ startHead?: ArrowHead;
161
+ /** Head on the (x2,y2) end. Absent = 'triangle' (back-compat). Phase 24 widened the enum. */
162
+ endHead?: ArrowHead;
163
+ /** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
164
+ dashed?: boolean;
165
+ /** Phase 24 — shaft routing. Absent = 'straight' (back-compat). */
166
+ lineType?: ArrowLineType;
167
+ /** FigJam v3 — magnetic binding of the (x1,y1) start to a host stroke. */
168
+ startBind?: ArrowBind;
169
+ /** FigJam v3 — magnetic binding of the (x2,y2) end to a host stroke. */
170
+ endBind?: ArrowBind;
171
+ }
172
+ export interface TextStroke extends StrokeBase {
173
+ tool: 'text';
174
+ color: string;
175
+ fontSize: number;
176
+ text: string;
177
+ /**
178
+ * Host shape id for anchored text (double-click a rect/ellipse). Phase 21
179
+ * relaxed this to optional: standalone text (the `text` tool) carries no
180
+ * anchor and renders at its own world `(x, y)` instead.
181
+ */
182
+ anchorId?: string;
183
+ /** Phase 21 — world coords for standalone (unanchored) text. */
184
+ x?: number;
185
+ y?: number;
186
+ /** Phase 24 — bold weight. Absent / false = normal (back-compat). */
187
+ bold?: boolean;
188
+ /** Phase 24 — strikethrough. Absent / false = none (back-compat). */
189
+ strike?: boolean;
190
+ /** Italic style (item 4b). Absent / false = upright (back-compat). */
191
+ italic?: boolean;
192
+ /** Underline (item 4b). Combined with strike into one text-decoration. */
193
+ underline?: boolean;
194
+ /** List style (item 4c). Markers are render-only — never stored in `text`. */
195
+ listType?: ListType;
196
+ /**
197
+ * Phase 24 — horizontal alignment. Absent default differs by kind: anchored
198
+ * text = 'center' (legacy, byte-identical), standalone = 'left'.
199
+ */
200
+ align?: TextAlign;
201
+ }
202
+ /** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
203
+ export interface StickyStroke extends StrokeBase {
204
+ tool: 'sticky';
205
+ color: string;
206
+ x: number;
207
+ y: number;
208
+ w: number;
209
+ h: number;
210
+ text: string;
211
+ fontSize: number;
212
+ /** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
213
+ cornerRadius?: number;
214
+ /** Phase 24 — bold body weight. Absent / false = normal. */
215
+ bold?: boolean;
216
+ /** Phase 24 — strikethrough body. Absent / false = none. */
217
+ strike?: boolean;
218
+ /** Italic body (item 4b). Absent / false = upright. */
219
+ italic?: boolean;
220
+ /** Underline body (item 4b). Combined with strike into one text-decoration. */
221
+ underline?: boolean;
222
+ /** List style (item 4c). Markers are render-only — never stored in `text`. */
223
+ listType?: ListType;
224
+ /** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */
225
+ align?: TextAlign;
226
+ }
227
+ /**
228
+ * Phase 23 — dropped / pasted raster image. Free-floating, rect-shaped, moves
229
+ * and resizes like any annotation. `href` is ALWAYS a relative
230
+ * `assets/<sha8>.<ext>` path (never a data: URL — keeps the persisted SVG under
231
+ * its 1 MB cap and matches the sanitizer's `<image>` href allowlist). The live
232
+ * canvas may briefly render an optimistic `blob:` href before the upload swaps
233
+ * it to the content-addressed path; only the `assets/…` form is ever persisted.
234
+ */
235
+ export interface ImageStroke extends StrokeBase {
236
+ tool: 'image';
237
+ x: number;
238
+ y: number;
239
+ w: number;
240
+ h: number;
241
+ href: string;
242
+ /**
243
+ * Alt text. Persisted in `data-alt` and emitted as `aria-label` on the
244
+ * `<image>`, so it travels with the exported / saved SVG (where AT reads it).
245
+ * NOTE: in the LIVE canvas the whole annotation SVG root is `aria-hidden`
246
+ * (editor chrome — AT shouldn't be flooded by decorative strokes), so the live
247
+ * in-canvas `aria-label` is pruned; the alt's audience is the export. Absent ⇒ ''.
248
+ */
249
+ alt?: string;
250
+ }
251
+ /**
252
+ * Phase 23 — pasted / dropped URL rendered as a client-only preview chip. NO
253
+ * server fetch and NO external favicon (the dev-server stays zero-egress —
254
+ * DDR-054/060). `title` comes from the clipboard/DnD `text/html` anchor text
255
+ * when present, else the prettified URL; `domain` is `new URL(url).hostname`.
256
+ * Persists as an allowlisted `<g>` (rect + vector glyph + two `<text>` runs) —
257
+ * the click-to-open handler reads `data-url`, no `<a href>` is ever stored.
258
+ */
259
+ export interface LinkStroke extends StrokeBase {
260
+ tool: 'link';
261
+ x: number;
262
+ y: number;
263
+ w: number;
264
+ h: number;
265
+ url: string;
266
+ title: string;
267
+ domain: string;
268
+ }
269
+ /**
270
+ * FigJam v3 — section: a labelled organizing container. Renders as a soft
271
+ * rounded region with a name chip above the top-left corner; its INTERIOR is
272
+ * click-through (only the border + chip select it) and dragging a section
273
+ * carries every stroke whose center sits inside it. Persists as an inert
274
+ * `<g data-tool="section" data-label>` wrapping the region rect + chip text.
275
+ */
276
+ export interface SectionStroke extends StrokeBase {
277
+ tool: 'section';
278
+ x: number;
279
+ y: number;
280
+ w: number;
281
+ h: number;
282
+ label: string;
283
+ /** Region tint (rendered at low opacity). */
284
+ color: string;
285
+ }
286
+
287
+ export type Stroke =
288
+ | PenStroke
289
+ | RectStroke
290
+ | EllipseStroke
291
+ | PolygonStroke
292
+ | ArrowStroke
293
+ | TextStroke
294
+ | StickyStroke
295
+ | ImageStroke
296
+ | LinkStroke
297
+ | SectionStroke;
298
+
299
+ /**
300
+ * Wave G — stroke types that can host anchored text (a TextStroke whose
301
+ * `anchorId` points at them). Every closed shape qualifies: rect, ellipse,
302
+ * and the polygon primitives (diamond / triangle). The text renders centered
303
+ * in the host's bbox and inherits its rotation.
304
+ */
305
+ export type AnchorHost = RectStroke | EllipseStroke | PolygonStroke;
306
+
307
+ /**
308
+ * Wave H — the selection chrome's breathing room, in SCREEN px (divide by
309
+ * zoom for world units). Single source for the halo rect, the resize-handle
310
+ * positions, and the resize cursor pad-correction, so they always sit on the
311
+ * same frame.
312
+ */
313
+ export const HALO_PAD_PX = 6;
314
+
315
+ // ─────────────────────────────────────────────────────────────────────────────
316
+ // Wave H — shape-kind conversion (the ctx-toolbar's square/rounded/circle/
317
+ // diamond/triangle switcher). Mirrors the tool palette's ShapeKind vocabulary.
318
+
319
+ export type ConvertibleShapeKind =
320
+ | 'square'
321
+ | 'rounded'
322
+ | 'circle'
323
+ | 'diamond'
324
+ | 'triangle'
325
+ | 'triangle-down';
326
+
327
+ /** The kind a closed shape currently is, or null for non-shape strokes. */
328
+ export function shapeKindOf(s: Stroke): ConvertibleShapeKind | null {
329
+ if (s.tool === 'rect') return (s.cornerRadius ?? 0) > 0 ? 'rounded' : 'square';
330
+ if (s.tool === 'ellipse') return 'circle';
331
+ if (s.tool === 'polygon') return s.shape;
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * Patch converting a closed shape to another kind, preserving its bbox,
337
+ * styling (color/width/fill/dashed), rotation, groups — and its ID, so
338
+ * anchored text and arrow bindings follow the conversion. Returns null for
339
+ * non-shapes and identity conversions. Stale source-geometry fields are
340
+ * explicitly cleared (they would not serialize, but a clean in-memory object
341
+ * keeps copy/paste payloads and debugging honest).
342
+ */
343
+ export function convertShapeKind(s: Stroke, kind: ConvertibleShapeKind): Partial<Stroke> | null {
344
+ const from = shapeKindOf(s);
345
+ if (from == null || from === kind) return null;
346
+ const bb = strokeBBox(s);
347
+ if (!bb) return null;
348
+ const clear = {
349
+ x: undefined,
350
+ y: undefined,
351
+ w: undefined,
352
+ h: undefined,
353
+ cx: undefined,
354
+ cy: undefined,
355
+ rx: undefined,
356
+ ry: undefined,
357
+ cornerRadius: undefined,
358
+ shape: undefined,
359
+ };
360
+ if (kind === 'square' || kind === 'rounded') {
361
+ return {
362
+ ...clear,
363
+ tool: 'rect',
364
+ x: bb.x,
365
+ y: bb.y,
366
+ w: bb.w,
367
+ h: bb.h,
368
+ cornerRadius: kind === 'rounded' ? 8 : 0,
369
+ } as unknown as Partial<Stroke>;
370
+ }
371
+ if (kind === 'circle') {
372
+ return {
373
+ ...clear,
374
+ tool: 'ellipse',
375
+ cx: bb.x + bb.w / 2,
376
+ cy: bb.y + bb.h / 2,
377
+ rx: Math.max(1, bb.w / 2),
378
+ ry: Math.max(1, bb.h / 2),
379
+ } as unknown as Partial<Stroke>;
380
+ }
381
+ return {
382
+ ...clear,
383
+ tool: 'polygon',
384
+ shape: kind,
385
+ x: bb.x,
386
+ y: bb.y,
387
+ w: bb.w,
388
+ h: bb.h,
389
+ } as unknown as Partial<Stroke>;
390
+ }
391
+
392
+ // ─────────────────────────────────────────────────────────────────────────────
393
+ // Palettes + defaults
394
+
395
+ // Phase 21 colour system — a single coherent hue family used everywhere.
396
+ // FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are
397
+ // light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use
398
+ // their own lightened paper set (STICKY_PALETTE). Exported so the draw-time
399
+ // chrome AND the per-selection context toolbar share ONE palette instead of
400
+ // drifting apart.
401
+ export const STROKE_PALETTE = [
402
+ '#e5484d', // red (default — markup ink)
403
+ '#f2762a', // orange
404
+ '#e0a500', // amber
405
+ '#30a46c', // green
406
+ '#3b82f6', // blue
407
+ '#8b5cf6', // purple
408
+ '#e93d82', // pink
409
+ '#7c7c7c', // gray
410
+ '#1f1f1f', // ink
411
+ ] as const;
412
+ export type PaletteColor = (typeof STROKE_PALETTE)[number];
413
+ // Phase 24 — default markup ink is BLACK (the `#1f1f1f` ink swatch, slot 8) for
414
+ // EVERY ink tool (pen / shape / arrow / text). It's a palette member so the
415
+ // draw chrome + per-selection toolbar highlight it as the active swatch; the
416
+ // other hues stay one click away. (Stickies keep their warm-paper default —
417
+ // DEFAULT_STICKY_COLOR — they're paper, not ink.)
418
+ export const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8];
419
+ // Annotation polish — the LIVE default ink follows the canvas theme so a
420
+ // freshly-armed pen/shape/arrow/text reads true on dark canvases (the
421
+ // `#1f1f1f` ink is near-invisible on a dark mock). Light → the `#1f1f1f`
422
+ // ink slot; dark → a light ink that reads on dark. This is the live draw
423
+ // default ONLY — `DEFAULT_COLOR` stays the parse fallback (round-trip
424
+ // determinism + back-compat), and stored strokes keep their literal hex
425
+ // (FigJam parity — no retroactive recolour).
426
+ const DEFAULT_INK_DARK = '#ededed';
427
+ export function resolveDefaultInk(theme: string): string {
428
+ return theme === 'dark' ? DEFAULT_INK_DARK : DEFAULT_COLOR;
429
+ }
430
+
431
+ // Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
432
+ // pale blue wash under a saturated stroke, exactly like FigJam shapes.
433
+ export const FILL_PALETTE = [
434
+ '#fbe0e1', // red tint
435
+ '#fce6d6', // orange tint
436
+ '#fbeec2', // amber tint
437
+ '#d9f1e2', // green tint
438
+ '#e0ebfd', // blue tint
439
+ '#ebe3fc', // purple tint
440
+ '#fbdfeb', // pink tint
441
+ '#ededed', // gray tint
442
+ '#e7e7e7', // ink tint
443
+ ] as const;
444
+
445
+ // Neutral fill wash for the ink slot (no paired hue) — light vs dark canvas.
446
+ const NEUTRAL_FILL_LIGHT = FILL_PALETTE[8]; // '#e7e7e7'
447
+ const NEUTRAL_FILL_DARK = '#2a2a2a';
448
+ /**
449
+ * Annotation polish (item 2) — the LIVE default fill for a freshly-armed Shape
450
+ * tool. A coloured ink maps to its index-paired light tint (FigJam: a saturated
451
+ * outline over a pale wash of the same hue); the ink slot / themed-dark ink /
452
+ * any unknown hex maps to a neutral wash. "No fill" stays one click away (the
453
+ * chrome's None swatch) and, once picked, sticks (fillTouchedRef). Stored
454
+ * shapes keep their literal fill — only NEW shapes pick up this default.
455
+ */
456
+ export function defaultFillFor(color: string, theme: string): string {
457
+ const idx = STROKE_PALETTE.indexOf(color as PaletteColor);
458
+ // Coloured ink (slots 0–7) → its paired tint; ink slot (8) / unknown → neutral.
459
+ if (idx >= 0 && idx < FILL_PALETTE.length - 1) return FILL_PALETTE[idx] as string;
460
+ return theme === 'dark' ? NEUTRAL_FILL_DARK : NEUTRAL_FILL_LIGHT;
461
+ }
462
+
463
+ export const STROKE_WIDTH_THIN = 3;
464
+ export const STROKE_WIDTH_THICK = 6;
465
+ export type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
466
+
467
+ const FONT_SIZE_MEDIUM = 14;
468
+ export const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
469
+
470
+ // Phase 24 — sticky-note paper tints. A muted/desaturated FigJam-style set
471
+ // (Image #2): a warm paper yellow default, then white/grey + soft pastels.
472
+ // Wholly separate from the stroke ink PALETTE and the translucent FILL_PALETTE
473
+ // so stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
474
+ // Existing stickies keep their stored hex; only NEW stickies pick up the new
475
+ // default tint.
476
+ export const STICKY_PALETTE = [
477
+ '#fce8a6', // muted yellow (default — warm paper)
478
+ '#ffffff', // white
479
+ '#e6e4e0', // light grey
480
+ '#f7c5c0', // salmon
481
+ '#f8d2a6', // peach
482
+ '#bfe3c0', // mint
483
+ '#a9dbdb', // aqua
484
+ '#bcd2f0', // light blue
485
+ '#cfc4ec', // lavender
486
+ '#f3c4dd', // light pink
487
+ ] as const;
488
+ export const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
489
+ export const STICKY_CORNER_RADIUS = 8;
490
+
491
+ // Annotation polish (item 8) — highlighter marker hues. Translucent 8-digit hex
492
+ // (RRGGBBAA, ~50% alpha) so overlaps darken under `mix-blend-mode:multiply`.
493
+ // Yellow is the default; green / pink / blue follow. Wholly separate from the
494
+ // ink PALETTE — the highlighter draws a soft wash, not a saturated line.
495
+ export const HIGHLIGHTER_PALETTE = [
496
+ '#ffe24d80', // yellow (default)
497
+ '#7ce8a080', // green
498
+ '#ff9ed180', // pink
499
+ '#7ec5ff80', // blue
500
+ ] as const;
501
+ export const DEFAULT_HIGHLIGHTER_COLOR = HIGHLIGHTER_PALETTE[0];
502
+ // Highlighter marker nib widths (item 8) — three sizes (thin / medium / thick),
503
+ // all wider than the pen. Default medium.
504
+ export const HIGHLIGHTER_WIDTHS = [10, 18, 28] as const;
505
+ export const DEFAULT_HIGHLIGHTER_WIDTH = HIGHLIGHTER_WIDTHS[1];
506
+ // Phase 24 — stickies are 1:1; the default tap size is a square.
507
+ export const STICKY_DEFAULT_W = 200;
508
+ export const STICKY_DEFAULT_H = 200;
509
+ export const STICKY_MIN_SIZE = 40;
510
+ // Phase 24 — a bare tap with the Shape tool drops a default-sized shape at the
511
+ // tap point (FigJam parity: click commits, drag sizes). Square aspect.
512
+ export const SHAPE_DEFAULT_SIZE = 120;
513
+
514
+ // FigJam v3 — section container defaults.
515
+ export const SECTION_DEFAULT_W = 480;
516
+ export const SECTION_DEFAULT_H = 320;
517
+ export const SECTION_MIN_SIZE = 64;
518
+ export const DEFAULT_SECTION_COLOR = '#8b8b94';
519
+ export const SECTION_CORNER_RADIUS = 12;
520
+ /** Label chip geometry (world units; the chip scales with the canvas). */
521
+ export const SECTION_LABEL_FONT = 12;
522
+ export const SECTION_LABEL_H = 20;
523
+
524
+ // Phase 23 — image + link media strokes.
525
+ /** Below this side an image stroke is discarded as an accidental micro-drop. */
526
+ export const IMAGE_MIN_SIZE = 16;
527
+ /** Longest side a freshly dropped/pasted image is scaled down to (world px). */
528
+ export const IMAGE_MAX_DROP_SIDE = 480;
529
+ export const LINK_DEFAULT_W = 260;
530
+ export const LINK_DEFAULT_H = 76;
531
+ export const LINK_CARD_FILL = '#ffffff';
532
+ export const LINK_CARD_STROKE = '#d4d4d8';
533
+ export const LINK_DOMAIN_FILL = '#71717a';
534
+ export const LINK_TITLE_FILL = '#18181b';
535
+ export const LINK_GLYPH_STROKE = '#52525b';
536
+ // Lucide "link" icon (24×24 viewBox) — two interlocked loops. ONE source for the
537
+ // serialized nested-<svg> glyph AND the StrokeNode render so re-serialize stays
538
+ // byte-stable. The parser ignores the glyph entirely (it reads data-* + the
539
+ // <rect> geometry), so render/serialize only need to agree visually.
540
+ export const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71';
541
+ export const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
542
+
543
+ /** Card text positions, derived purely from the bbox (idempotent round-trip). */
544
+ export function linkCardLayout(x: number, y: number, w: number, h: number) {
545
+ const textX = x + 48;
546
+ return {
547
+ glyph: { x: x + 16, y: y + h / 2 - 10, size: 20 },
548
+ textX,
549
+ domain: { y: y + h / 2 - 14, fontSize: 11 },
550
+ title: { y: y + h / 2, fontSize: 13 },
551
+ textMaxChars: Math.max(8, Math.floor((w - 60) / 7)),
552
+ };
553
+ }
554
+
555
+ /** Clamp a link title to the card's character budget (pure → byte-stable). */
556
+ export function clampLinkTitle(title: string, maxChars: number): string {
557
+ return title.length > maxChars ? `${title.slice(0, Math.max(1, maxChars - 1))}…` : title;
558
+ }
559
+
560
+ // ─────────────────────────────────────────────────────────────────────────────
561
+ // Pure helpers — exported for unit tests.
562
+
563
+ export function rid(): string {
564
+ return `s_${Math.random().toString(36).slice(2, 10)}`;
565
+ }
566
+
567
+ /** FigJam v3 — group ids mirror the stroke id scheme (`g_` prefix). */
568
+ export function gid(): string {
569
+ return `g_${Math.random().toString(36).slice(2, 10)}`;
570
+ }
571
+
572
+ export function esc(s: string): string {
573
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
574
+ }
575
+
576
+ /**
577
+ * Phase 23 — attribute-safe escape: `esc()` plus `>`. The legacy text/sticky
578
+ * paths only put user text in element CONTENT (where a bare `>` is harmless),
579
+ * so `esc()` never escaped it; but the media strokes carry user text (pasted
580
+ * link title/url, image alt) inside ATTRIBUTES (data-title / data-url / data-alt
581
+ * / href). A bare `>` there would prematurely close the tag and confuse the
582
+ * `[^>]*>` element scan in `sanitizeAnnotationSvg`. Use this for every media
583
+ * attribute value; element CONTENT keeps plain `esc()`.
584
+ */
585
+ export function escAttr(s: string): string {
586
+ return esc(s).replace(/>/g, '&gt;');
587
+ }
588
+
589
+ export function penPathD(points: readonly WorldPoint[]): string {
590
+ if (points.length === 0) return '';
591
+ const [first, ...rest] = points as readonly WorldPoint[];
592
+ if (!first) return '';
593
+ let d = `M${first[0]} ${first[1]}`;
594
+ for (const p of rest) d += ` L${p[0]} ${p[1]}`;
595
+ return d;
596
+ }
597
+
598
+ // ── Multi-line text (item 4a) ────────────────────────────────────────────────
599
+ // SVG <text> ignores `\n`, so multi-line annotation text must render as one
600
+ // <tspan> per line. The serialized + live forms share this geometry; single-
601
+ // line text keeps the legacy single-run form (no tspan) so the canary holds.
602
+
603
+ /** Line-height multiplier for multi-line annotation text. */
604
+ export const TEXT_LINE_HEIGHT = 1.25;
605
+
606
+ /** Split a text body into its display lines. */
607
+ export function splitTextLines(text: string): string[] {
608
+ return text.split('\n');
609
+ }
610
+
611
+ /**
612
+ * Render-time list marker prefix for one line. Markers are PRESENTATION ONLY —
613
+ * never stored in `text` (DDR) — so the stored string stays clean and
614
+ * contentEditable editing is sane. Bullet → `• `; number → `${i + 1}. `.
615
+ */
616
+ export function listPrefixedLine(line: string, index: number, list?: ListType): string {
617
+ if (!list) return line;
618
+ return list === 'bullet' ? `• ${line}` : `${index + 1}. ${line}`;
619
+ }
620
+
621
+ /** Inverse of {@link listPrefixedLine} — strip a render-time marker on parse. */
622
+ export function stripListPrefix(line: string, index: number, list?: ListType): string {
623
+ if (!list) return line;
624
+ const marker = list === 'bullet' ? '• ' : `${index + 1}. `;
625
+ return line.startsWith(marker) ? line.slice(marker.length) : line;
626
+ }
627
+
628
+ /** Prefix every line of a body with its list marker (for the editor display). */
629
+ export function listPrefixedBody(text: string, list?: ListType): string {
630
+ if (!list) return text;
631
+ return splitTextLines(text)
632
+ .map((line, i) => listPrefixedLine(line, i, list))
633
+ .join('\n');
634
+ }
635
+
636
+ /**
637
+ * Strip list markers off editor `innerText` on commit (item 4c). Generic — a
638
+ * `•` bullet OR any leading `N. ` number is removed once per line, regardless of
639
+ * the index the user actually typed, so re-numbering while editing round-trips
640
+ * cleanly (the stored text stays marker-free; the read view re-derives markers).
641
+ */
642
+ export function stripEditorMarkers(text: string, list?: ListType): string {
643
+ if (!list) return text;
644
+ const re = list === 'bullet' ? /^• / : /^\d+\.\s/;
645
+ return splitTextLines(text)
646
+ .map((line) => line.replace(re, ''))
647
+ .join('\n');
648
+ }
649
+
650
+ /**
651
+ * Combined `text-decoration` SVG attribute for strike + underline (item 4b).
652
+ * Strike-only stays `text-decoration="line-through"` (byte-identical to the
653
+ * legacy Phase-24 form); both → `line-through underline`; neither → empty.
654
+ */
655
+ export function textDecoAttr(strike?: boolean, underline?: boolean): string {
656
+ const vals: string[] = [];
657
+ if (strike) vals.push('line-through');
658
+ if (underline) vals.push('underline');
659
+ return vals.length ? ` text-decoration="${vals.join(' ')}"` : '';
660
+ }
661
+
662
+ /** CSS `text-decoration` value for the live render (strike + underline). */
663
+ export function textDecoCss(strike?: boolean, underline?: boolean): string | undefined {
664
+ const vals: string[] = [];
665
+ if (strike) vals.push('line-through');
666
+ if (underline) vals.push('underline');
667
+ return vals.length ? vals.join(' ') : undefined;
668
+ }
669
+
670
+ /**
671
+ * Inline text formatting carried by an editor through commit (item 4b/4d
672
+ * unification) — so Cmd+B / Cmd+I / Cmd+U toggled WHILE editing land on the
673
+ * stroke. `strike` rides along unchanged (no shortcut; toolbar-only).
674
+ */
675
+ export interface EditorFmt {
676
+ bold?: boolean;
677
+ italic?: boolean;
678
+ underline?: boolean;
679
+ strike?: boolean;
680
+ /** FigJam v3 — live-editable font size (edit-mode text toolbar). */
681
+ fontSize?: number;
682
+ /** FigJam v3 — live-editable alignment (edit-mode text toolbar). */
683
+ align?: TextAlign;
684
+ }
685
+ /** Normalize an EditorFmt → only-true keys kept; false becomes undefined so the
686
+ * serialize-only-when-set invariant + byte-identical canary hold. */
687
+ export function normFmt(fmt?: EditorFmt): EditorFmt {
688
+ const out: EditorFmt = {
689
+ bold: fmt?.bold || undefined,
690
+ italic: fmt?.italic || undefined,
691
+ underline: fmt?.underline || undefined,
692
+ strike: fmt?.strike || undefined,
693
+ };
694
+ // FigJam v3 — carry the edit-mode toolbar's size/align through commit, but
695
+ // ONLY when set: an absent key must not clobber the stroke's stored value
696
+ // (the spread `{ ...stroke, ...normFmt(fmt) }` would write undefined).
697
+ if (typeof fmt?.fontSize === 'number' && Number.isFinite(fmt.fontSize)) {
698
+ out.fontSize = fmt.fontSize;
699
+ }
700
+ if (fmt?.align) out.align = fmt.align;
701
+ return out;
702
+ }
703
+ /** True when a stroke's existing formatting already matches `fmt` (so a pure
704
+ * identity edit can short-circuit without a redundant undo record). */
705
+ export function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
706
+ if (!fmt) return true;
707
+ if (typeof fmt.fontSize === 'number' && s.fontSize !== fmt.fontSize) return false;
708
+ if (fmt.align && s.align !== fmt.align) return false;
709
+ return (
710
+ !!s.bold === !!fmt.bold &&
711
+ !!s.italic === !!fmt.italic &&
712
+ !!s.underline === !!fmt.underline &&
713
+ !!s.strike === !!fmt.strike
714
+ );
715
+ }
716
+
717
+ /**
718
+ * Per-line baseline offset (`dy`). Line 0 sits at the anchor when top-anchored
719
+ * (hanging) or is lifted half the block height when vertically centred
720
+ * (anchored-in-host); every later line advances one line-height.
721
+ */
722
+ export function textLineDy(
723
+ i: number,
724
+ fontSize: number,
725
+ lineCount: number,
726
+ centered: boolean
727
+ ): number {
728
+ const lh = fontSize * TEXT_LINE_HEIGHT;
729
+ if (i > 0) return lh;
730
+ return centered ? (-(lineCount - 1) / 2) * lh : 0;
731
+ }
732
+
733
+ /**
734
+ * Inner content for a serialized `<text>` stroke: the legacy single esc'd run
735
+ * when there's no newline (byte-identical, canary-safe), else one `<tspan>` per
736
+ * line. `x` is set on each tspan for standalone text (resets the line origin);
737
+ * anchored text omits it (the persisted form carries no absolute position —
738
+ * geometry is resolved against the host at render time). A `list` prefix
739
+ * (bullet / number) is prepended per line at render time only (DDR — never
740
+ * stored in `text`).
741
+ */
742
+ function textInnerSvg(
743
+ text: string,
744
+ fontSize: number,
745
+ centered: boolean,
746
+ x: number | undefined,
747
+ list?: ListType
748
+ ): string {
749
+ if (!list && !text.includes('\n')) return esc(listPrefixedLine(text, 0, list));
750
+ const lines = splitTextLines(text);
751
+ const xAttr = x != null ? ` x="${x}"` : '';
752
+ return lines
753
+ .map(
754
+ (line, i) =>
755
+ `<tspan${xAttr} dy="${textLineDy(i, fontSize, lines.length, centered)}">${esc(
756
+ listPrefixedLine(line, i, list)
757
+ )}</tspan>`
758
+ )
759
+ .join('');
760
+ }
761
+
762
+ /**
763
+ * Phase 24 — polygon vertices derived from the bbox. `diamond` = the four
764
+ * edge-midpoints; `triangle` = apex-up; `triangle-down` = apex-down. Every
765
+ * shape's vertices span the FULL bbox, so a parse-back via the points' min/max
766
+ * recovers x/y/w/h exactly (idempotent round-trip).
767
+ */
768
+ export function polygonVertices(
769
+ shape: PolygonShape,
770
+ x: number,
771
+ y: number,
772
+ w: number,
773
+ h: number
774
+ ): Array<[number, number]> {
775
+ if (shape === 'diamond') {
776
+ return [
777
+ [x + w / 2, y],
778
+ [x + w, y + h / 2],
779
+ [x + w / 2, y + h],
780
+ [x, y + h / 2],
781
+ ];
782
+ }
783
+ if (shape === 'triangle') {
784
+ return [
785
+ [x + w / 2, y],
786
+ [x + w, y + h],
787
+ [x, y + h],
788
+ ];
789
+ }
790
+ // triangle-down — apex at the bottom.
791
+ return [
792
+ [x, y],
793
+ [x + w, y],
794
+ [x + w / 2, y + h],
795
+ ];
796
+ }
797
+
798
+ /** Vertices as an SVG `points` string. */
799
+ export function polygonPoints(
800
+ shape: PolygonShape,
801
+ x: number,
802
+ y: number,
803
+ w: number,
804
+ h: number
805
+ ): string {
806
+ return polygonVertices(shape, x, y, w, h)
807
+ .map(([px, py]) => `${px},${py}`)
808
+ .join(' ');
809
+ }
810
+
811
+ /**
812
+ * Annotation polish (item 1) — a rounded-rect `d` with TL/TR/BL rounded at `r`
813
+ * and the **bottom-right corner SHARP** (the FigJam sticky-note silhouette). The
814
+ * radius is clamped to half the smaller side so it never self-overlaps. Used by
815
+ * `StrokeNode`'s LIVE sticky render only; the persisted form (`strokeToSvgEl`)
816
+ * stays a plain `<rect>` (DDR — zero canary / sanitizer / parse impact).
817
+ */
818
+ export function stickyCornerPath(x: number, y: number, w: number, h: number, r: number): string {
819
+ const rr = Math.max(0, Math.min(r, w / 2, h / 2));
820
+ return [
821
+ `M${x + rr} ${y}`,
822
+ `L${x + w - rr} ${y}`,
823
+ `Q${x + w} ${y} ${x + w} ${y + rr}`,
824
+ `L${x + w} ${y + h}`, // sharp bottom-right
825
+ `L${x + rr} ${y + h}`,
826
+ `Q${x} ${y + h} ${x} ${y + h - rr}`,
827
+ `L${x} ${y + rr}`,
828
+ `Q${x} ${y} ${x + rr} ${y}`,
829
+ 'Z',
830
+ ].join(' ');
831
+ }
832
+
833
+ /** Even-odd ray-cast point-in-polygon test. */
834
+ function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
835
+ let inside = false;
836
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
837
+ const a = pts[i];
838
+ const b = pts[j];
839
+ if (!a || !b) continue;
840
+ const [xi, yi] = a;
841
+ const [xj, yj] = b;
842
+ if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
843
+ inside = !inside;
844
+ }
845
+ }
846
+ return inside;
847
+ }
848
+
849
+ /** Parse a polygon `points` string back into its bounding box. */
850
+ function polygonBBox(points: string): { x: number; y: number; w: number; h: number } | null {
851
+ let xMin = Number.POSITIVE_INFINITY;
852
+ let yMin = Number.POSITIVE_INFINITY;
853
+ let xMax = Number.NEGATIVE_INFINITY;
854
+ let yMax = Number.NEGATIVE_INFINITY;
855
+ for (const pair of points.trim().split(/\s+/)) {
856
+ const [px, py] = pair.split(',').map((n) => Number.parseFloat(n));
857
+ // Skip non-finite points (NaN AND Infinity from a poisoned `1e999`) so a
858
+ // bad vertex can't push the bbox to an Infinity extent — Wave H F4.
859
+ if (px == null || py == null || !Number.isFinite(px) || !Number.isFinite(py)) continue;
860
+ if (px < xMin) xMin = px;
861
+ if (px > xMax) xMax = px;
862
+ if (py < yMin) yMin = py;
863
+ if (py > yMax) yMax = py;
864
+ }
865
+ if (!Number.isFinite(xMin)) return null;
866
+ return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
867
+ }
868
+
869
+ // ─────────────────────────────────────────────────────────────────────────────
870
+ // Serialize
871
+
872
+ /**
873
+ * FigJam v3 — cross-tool root attributes (group membership / AI provenance /
874
+ * arrow binds), serialized ONLY for non-default values so every legacy stroke
875
+ * stays byte-identical (the canary). Injected into the stroke's root tag.
876
+ */
877
+ function rootExtraAttrs(s: Stroke): string {
878
+ let extra = '';
879
+ if (s.tool === 'arrow') {
880
+ if (s.startBind) {
881
+ const b = s.startBind;
882
+ extra += ` data-start-bind="${esc(b.hostId)} ${b.nx} ${b.ny}${b.pinned ? ' p' : ''}"`;
883
+ }
884
+ if (s.endBind) {
885
+ const b = s.endBind;
886
+ extra += ` data-end-bind="${esc(b.hostId)} ${b.nx} ${b.ny}${b.pinned ? ' p' : ''}"`;
887
+ }
888
+ }
889
+ if (s.groupIds && s.groupIds.length > 0) {
890
+ extra += ` data-group-ids="${esc(s.groupIds.join(' '))}"`;
891
+ }
892
+ if (s.author === 'ai') extra += ' data-author="ai"';
893
+ // FigJam v3 — rotation: `data-rot` is the round-trip source of truth; the
894
+ // transform is presentational so the persisted SVG renders rotated in any
895
+ // viewer. The pivot derives from stored geometry → idempotent re-serialize.
896
+ const rot = strokeRotation(s);
897
+ if (rot !== 0) {
898
+ const c = strokeCenter(s);
899
+ if (c) extra += ` data-rot="${rot}" transform="rotate(${rot} ${c[0]} ${c[1]})"`;
900
+ }
901
+ return extra;
902
+ }
903
+
904
+ /**
905
+ * Insert extra attributes at the end of the root tag's attribute list. Safe
906
+ * because no legit attribute VALUE contains a literal `>` (media attrs are
907
+ * escAttr'd; ids/colors/enums can't carry one), so the first `>` is always the
908
+ * end of the opening tag.
909
+ */
910
+ function injectRootAttrs(el: string, extra: string): string {
911
+ if (!extra) return el;
912
+ const gt = el.indexOf('>');
913
+ if (gt < 0) return el;
914
+ const insertAt = el[gt - 1] === '/' ? gt - 1 : gt;
915
+ return el.slice(0, insertAt) + extra + el.slice(insertAt);
916
+ }
917
+
918
+ export function strokeToSvgEl(s: Stroke): string {
919
+ return injectRootAttrs(strokeToSvgElBase(s), rootExtraAttrs(s));
920
+ }
921
+
922
+ function strokeToSvgElBase(s: Stroke): string {
923
+ if (s.tool === 'text') {
924
+ // Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
925
+ // standalone text (no anchorId) writes its own world x/y and omits
926
+ // data-anchor-id (so the parser routes it back to the standalone branch).
927
+ // bold/italic/strike/underline/align/list serialize ONLY for non-default
928
+ // values, so a legacy text node stays byte-identical (every added fragment
929
+ // is empty). Multi-line text emits one <tspan> per line (item 4a); a
930
+ // single-line unstyled run stays the legacy single esc'd text.
931
+ const weight = s.bold ? ' font-weight="700"' : '';
932
+ const italic = s.italic ? ' font-style="italic"' : '';
933
+ const deco = textDecoAttr(s.strike, s.underline);
934
+ // esc() the enum attrs too — a no-op for the constrained values, but it
935
+ // keeps the "every value reaching serialize is escaped" invariant uniform
936
+ // with the arrow-head attrs (Wave H defender suggestion, DDR-067).
937
+ const listAttr = s.listType ? ` data-list="${esc(s.listType)}"` : '';
938
+ if (s.anchorId != null && s.anchorId !== '') {
939
+ const align = s.align ?? 'center'; // anchored default = centre (legacy)
940
+ const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
941
+ const alignAttr = align !== 'center' ? ` data-align="${esc(align)}"` : '';
942
+ return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
943
+ s.anchorId
944
+ )}" data-font-size="${s.fontSize}" fill="${esc(
945
+ s.color
946
+ )}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${textInnerSvg(
947
+ s.text,
948
+ s.fontSize,
949
+ true,
950
+ undefined,
951
+ s.listType
952
+ )}</text>`;
953
+ }
954
+ const tx = s.x ?? 0;
955
+ const ty = s.y ?? 0;
956
+ const align = s.align ?? 'left'; // standalone default = left
957
+ const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
958
+ const alignAttr = align !== 'left' ? ` data-align="${esc(align)}"` : '';
959
+ return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
960
+ s.fontSize
961
+ }" fill="${esc(
962
+ s.color
963
+ )}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${textInnerSvg(
964
+ s.text,
965
+ s.fontSize,
966
+ false,
967
+ tx,
968
+ s.listType
969
+ )}</text>`;
970
+ }
971
+ if (s.tool === 'sticky') {
972
+ // Phase 21 — sticky body lives in an allowlisted <text> child so it
973
+ // survives sanitizeAnnotationSvg (which strips <foreignObject>, DDR-060
974
+ // F1). The live canvas re-renders this stroke with a foreignObject so the
975
+ // text word-wraps; the persisted <text> is the inert, sanitizer-safe form.
976
+ const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
977
+ const w = Math.max(0, s.w);
978
+ const h = Math.max(0, s.h);
979
+ // bold/italic/strike/underline/align/list on the <g> data-attrs, emitted
980
+ // ONLY for non-default values (sticky default align = left) so Phase-21
981
+ // stickies serialize byte-identically. The body <text> stays raw text —
982
+ // list markers are render-only (item 4c), never persisted.
983
+ const align = s.align ?? 'left';
984
+ const styleAttrs =
985
+ (s.bold ? ' data-bold="1"' : '') +
986
+ (s.italic ? ' data-italic="1"' : '') +
987
+ (s.strike ? ' data-strike="1"' : '') +
988
+ (s.underline ? ' data-underline="1"' : '') +
989
+ (align !== 'left' ? ` data-align="${esc(align)}"` : '') +
990
+ (s.listType ? ` data-list="${esc(s.listType)}"` : '');
991
+ return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
992
+ s.fontSize
993
+ }" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
994
+ s.y
995
+ }" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
996
+ s.x + 12
997
+ }" y="${s.y + 12}" font-size="${
998
+ s.fontSize
999
+ }" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
1000
+ }
1001
+ if (s.tool === 'image') {
1002
+ // Phase 23 — `href` is ALWAYS a relative assets/<sha8>.<ext> path (asserted
1003
+ // on create + re-validated by the sanitizer's <image> href allowlist). Alt
1004
+ // text persists in `data-alt` + is emitted as `aria-label` for the exported
1005
+ // SVG (the live annotation root is aria-hidden — see ImageStroke.alt).
1006
+ const nx = Math.min(s.x, s.x + s.w);
1007
+ const ny = Math.min(s.y, s.y + s.h);
1008
+ const nw = Math.abs(s.w);
1009
+ const nh = Math.abs(s.h);
1010
+ const altAttr = s.alt ? ` data-alt="${escAttr(s.alt)}"` : '';
1011
+ return `<image data-id="${esc(s.id)}" data-tool="image" x="${nx}" y="${ny}" width="${nw}" height="${nh}" href="${escAttr(
1012
+ s.href
1013
+ )}" preserveAspectRatio="xMidYMid meet"${altAttr}/>`;
1014
+ }
1015
+ if (s.tool === 'link') {
1016
+ // Phase 23 — client-only preview chip. data-url/title/domain are the
1017
+ // round-trip source of truth; the inner rect/glyph/text are the inert,
1018
+ // sanitizer-safe visual (no <a href> persisted — click-to-open reads
1019
+ // data-url client-side and validates http(s) before window.open).
1020
+ const nx = Math.min(s.x, s.x + s.w);
1021
+ const ny = Math.min(s.y, s.y + s.h);
1022
+ const nw = Math.abs(s.w);
1023
+ const nh = Math.abs(s.h);
1024
+ const lay = linkCardLayout(nx, ny, nw, nh);
1025
+ const shownTitle = clampLinkTitle(s.title, lay.textMaxChars);
1026
+ return (
1027
+ `<g data-id="${esc(s.id)}" data-tool="link" data-url="${escAttr(s.url)}" data-title="${escAttr(
1028
+ s.title
1029
+ )}" data-domain="${escAttr(s.domain)}">` +
1030
+ `<rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="8" ry="8" fill="${LINK_CARD_FILL}" stroke="${LINK_CARD_STROKE}" stroke-width="1"/>` +
1031
+ `<svg x="${lay.glyph.x}" y="${lay.glyph.y}" width="${lay.glyph.size}" height="${lay.glyph.size}" viewBox="0 0 24 24" fill="none" stroke="${LINK_GLYPH_STROKE}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${LINK_GLYPH_D1}"/><path d="${LINK_GLYPH_D2}"/></svg>` +
1032
+ `<text x="${lay.textX}" y="${lay.domain.y}" font-size="${lay.domain.fontSize}" fill="${LINK_DOMAIN_FILL}" dominant-baseline="hanging">${esc(
1033
+ s.domain
1034
+ )}</text>` +
1035
+ `<text x="${lay.textX}" y="${lay.title.y}" font-size="${lay.title.fontSize}" fill="${LINK_TITLE_FILL}" font-weight="600" dominant-baseline="hanging">${esc(
1036
+ shownTitle
1037
+ )}</text>` +
1038
+ `</g>`
1039
+ );
1040
+ }
1041
+ if (s.tool === 'section') {
1042
+ // FigJam v3 — inert persisted form: region rect + chip label text. Colors
1043
+ // are render-time chrome; the parser reads geometry off the rect and the
1044
+ // label off data-label (the inner <text> is presentational, like sticky).
1045
+ const nx = Math.min(s.x, s.x + s.w);
1046
+ const ny = Math.min(s.y, s.y + s.h);
1047
+ const nw = Math.abs(s.w);
1048
+ const nh = Math.abs(s.h);
1049
+ return `<g data-id="${esc(s.id)}" data-tool="section" data-label="${escAttr(
1050
+ s.label
1051
+ )}" fill="${esc(s.color)}"><rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="${SECTION_CORNER_RADIUS}" ry="${SECTION_CORNER_RADIUS}" fill-opacity="0.06"/><text x="${
1052
+ nx + 2
1053
+ }" y="${ny - 8}" font-size="${SECTION_LABEL_FONT}">${esc(s.label)}</text></g>`;
1054
+ }
1055
+ 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"`;
1056
+ if (s.tool === 'pen') {
1057
+ // Highlighter (item 8) — data-highlighter ONLY when true so a normal pen
1058
+ // stays byte-identical (canary).
1059
+ const hl = s.highlighter ? ' data-highlighter="1"' : '';
1060
+ return `<path ${common} fill="none" d="${penPathD(s.points)}"${hl} pointer-events="stroke"/>`;
1061
+ }
1062
+ if (s.tool === 'rect') {
1063
+ const fill = s.fill ? esc(s.fill) : 'none';
1064
+ // Phase 21 — corner radius: append rx/ry/data-r ONLY when > 0 so legacy
1065
+ // sharp-corner rects serialize byte-identically (Task 10 canary).
1066
+ const r = s.cornerRadius ?? 0;
1067
+ const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
1068
+ // Dashed outline (item 7) — emitted ONLY when true (mirror polygon/arrow)
1069
+ // so a legacy solid rect stays byte-identical.
1070
+ const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
1071
+ const dashAttr = s.dashed ? ' data-dash="1"' : '';
1072
+ return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
1073
+ 0,
1074
+ s.w
1075
+ )}" height="${Math.max(0, s.h)}"${round}${dash}${dashAttr}/>`;
1076
+ }
1077
+ if (s.tool === 'ellipse') {
1078
+ const fill = s.fill ? esc(s.fill) : 'none';
1079
+ const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
1080
+ const dashAttr = s.dashed ? ' data-dash="1"' : '';
1081
+ return `<ellipse ${common} fill="${fill}" cx="${s.cx}" cy="${s.cy}" rx="${Math.max(
1082
+ 0,
1083
+ s.rx
1084
+ )}" ry="${Math.max(0, s.ry)}"${dash}${dashAttr}/>`;
1085
+ }
1086
+ if (s.tool === 'polygon') {
1087
+ // Phase 24 — bbox-derived points + data-shape. Normalize the bbox so a
1088
+ // negative-extent (mid-flip) stroke serializes idempotently.
1089
+ const nx = Math.min(s.x, s.x + s.w);
1090
+ const ny = Math.min(s.y, s.y + s.h);
1091
+ const nw = Math.abs(s.w);
1092
+ const nh = Math.abs(s.h);
1093
+ const fill = s.fill ? esc(s.fill) : 'none';
1094
+ const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
1095
+ const dashAttr = s.dashed ? ' data-dash="1"' : '';
1096
+ return `<polygon ${common} fill="${fill}" data-shape="${esc(s.shape)}" points="${polygonPoints(
1097
+ s.shape,
1098
+ nx,
1099
+ ny,
1100
+ nw,
1101
+ nh
1102
+ )}"${dash}${dashAttr}/>`;
1103
+ }
1104
+ // arrow — Phase 24 reduces to ordered SVG primitives (canvas-arrowheads), the
1105
+ // same primitives StrokeNode renders. Defaults (startHead 'none', endHead
1106
+ // 'triangle', lineType 'straight', solid) reduce to exactly
1107
+ // [<line>, <polyline fill=color>] → the byte-identical Phase 5.1 form. data-*
1108
+ // attrs appear only for non-default values.
1109
+ const startHead = s.startHead ?? 'none';
1110
+ const endHead = s.endHead ?? 'triangle';
1111
+ const lineType = s.lineType ?? 'straight';
1112
+ const dashed = s.dashed ?? false;
1113
+ // esc() every interpolated value (defence-in-depth, Phase 24 security review
1114
+ // DDR-067) — heads are clamped on parse, but a value reaching serialize must
1115
+ // never be able to break out of the attribute.
1116
+ const dataAttrs =
1117
+ (startHead !== 'none' ? ` data-start-head="${esc(startHead)}"` : '') +
1118
+ (endHead !== 'triangle' ? ` data-end-head="${esc(endHead)}"` : '') +
1119
+ (lineType !== 'straight' ? ` data-line-type="${esc(lineType)}"` : '') +
1120
+ (dashed ? ' data-dash="1"' : '');
1121
+ const body = arrowPrimitives(s).map(svgPrimitiveToString).join('');
1122
+ return `<g ${common} fill="none"${dataAttrs}>${body}</g>`;
1123
+ }
1124
+
1125
+ /** Format one arrow SVG primitive for the persisted string (byte-identical to
1126
+ * the Phase-5.1 `<line>`/`<polyline>` forms for the legacy default arrow). */
1127
+ function svgPrimitiveToString(p: SvgPrimitive): string {
1128
+ const dash = 'dash' in p && p.dash ? ' stroke-dasharray="6 4"' : '';
1129
+ switch (p.el) {
1130
+ case 'line':
1131
+ return `<line x1="${p.x1}" y1="${p.y1}" x2="${p.x2}" y2="${p.y2}"${dash}/>`;
1132
+ case 'path':
1133
+ return `<path d="${p.d}"${dash}/>`;
1134
+ case 'polyline':
1135
+ return `<polyline points="${p.points}" fill="${esc(p.fill)}"/>`;
1136
+ case 'polygon':
1137
+ return `<polygon points="${p.points}" fill="${esc(p.fill)}"/>`;
1138
+ case 'circle':
1139
+ return `<circle cx="${p.cx}" cy="${p.cy}" r="${p.r}" fill="${esc(p.fill)}"/>`;
1140
+ }
1141
+ }
1142
+
1143
+ export function strokesToSvg(strokes: readonly Stroke[]): string {
1144
+ const header = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
1145
+ if (strokes.length === 0) return `${header}</svg>`;
1146
+ const body = strokes.map(strokeToSvgEl).join('');
1147
+ return `${header}${body}</svg>`;
1148
+ }
1149
+
1150
+ // ─────────────────────────────────────────────────────────────────────────────
1151
+ // Parse
1152
+
1153
+ /**
1154
+ * Parse a numeric attribute, clamping non-finite results to `fallback`.
1155
+ * A poisoned/synced SVG can carry `x="1e999"` (→ Infinity) or `"abc"` (→ NaN);
1156
+ * those must NOT survive into geometry, because the `read-annotations` reader
1157
+ * coerces non-finite to 0 while the renderer would keep Infinity — the AI and
1158
+ * the human would then see the stroke at different positions (a content-spoof
1159
+ * side-channel — Wave H security review F4). Clamping here keeps the two views
1160
+ * in agreement and protects the bind / resize math from NaN propagation.
1161
+ */
1162
+ function pfloat(raw: string | null | undefined, fallback = 0): number {
1163
+ const v = Number.parseFloat(raw ?? '');
1164
+ return Number.isFinite(v) ? v : fallback;
1165
+ }
1166
+
1167
+ function parsePathD(d: string): WorldPoint[] {
1168
+ const out: WorldPoint[] = [];
1169
+ const re = /[ML]\s*(-?\d+(?:\.\d+)?)\s*[\s,]\s*(-?\d+(?:\.\d+)?)/g;
1170
+ let m: RegExpExecArray | null;
1171
+ // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
1172
+ while ((m = re.exec(d)) !== null) {
1173
+ const [, x = '0', y = '0'] = m;
1174
+ out.push([pfloat(x), pfloat(y)]);
1175
+ }
1176
+ return out;
1177
+ }
1178
+
1179
+ function parseFill(raw: string | null): string | null {
1180
+ if (!raw) return null;
1181
+ const v = raw.trim().toLowerCase();
1182
+ if (!v || v === 'none' || v === 'transparent') return null;
1183
+ return raw;
1184
+ }
1185
+
1186
+ /**
1187
+ * Phase 24 — recover an arrow's two endpoints from its shaft. A straight arrow
1188
+ * persists a `<line>`; a curved/elbow arrow persists a `<path>` whose first and
1189
+ * last coordinate pairs are the endpoints (the bow control / elbow corner sit
1190
+ * between them, so first-pair = start, last-pair = end recovers the ends
1191
+ * exactly → idempotent re-serialize).
1192
+ */
1193
+ function arrowEndpoints(el: Element): { x1: number; y1: number; x2: number; y2: number } | null {
1194
+ const line = el.querySelector('line');
1195
+ if (line) {
1196
+ return {
1197
+ x1: pfloat(line.getAttribute('x1')),
1198
+ y1: pfloat(line.getAttribute('y1')),
1199
+ x2: pfloat(line.getAttribute('x2')),
1200
+ y2: pfloat(line.getAttribute('y2')),
1201
+ };
1202
+ }
1203
+ const path = el.querySelector('path');
1204
+ if (path) {
1205
+ const nums = (path.getAttribute('d') || '').match(/-?\d+(?:\.\d+)?/g);
1206
+ if (nums && nums.length >= 4) {
1207
+ return {
1208
+ x1: pfloat(nums[0]),
1209
+ y1: pfloat(nums[1]),
1210
+ x2: pfloat(nums[nums.length - 2]),
1211
+ y2: pfloat(nums[nums.length - 1]),
1212
+ };
1213
+ }
1214
+ }
1215
+ return null;
1216
+ }
1217
+
1218
+ /**
1219
+ * FigJam v3 — parse a `data-start-bind` / `data-end-bind` value (`"<hostId>
1220
+ * <nx> <ny>"`). Out-of-range / malformed values are rejected (a poisoned
1221
+ * hub-pushed SVG must not cast through unchecked — same posture as the
1222
+ * arrowhead clamp, DDR-067).
1223
+ */
1224
+ function parseBindAttr(raw: string | null): ArrowBind | null {
1225
+ if (!raw) return null;
1226
+ const parts = raw.trim().split(/\s+/);
1227
+ if (parts.length !== 3 && !(parts.length === 4 && parts[3] === 'p')) return null;
1228
+ const [hostId, nxRaw, nyRaw] = parts;
1229
+ const nx = Number.parseFloat(nxRaw ?? '');
1230
+ const ny = Number.parseFloat(nyRaw ?? '');
1231
+ if (!hostId || !Number.isFinite(nx) || !Number.isFinite(ny)) return null;
1232
+ if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return null;
1233
+ const bind: ArrowBind = { hostId, nx, ny };
1234
+ if (parts.length === 4) bind.pinned = true;
1235
+ return bind;
1236
+ }
1237
+
1238
+ /** FigJam v3 — read the cross-tool root attrs back onto a parsed stroke. */
1239
+ function readSharedAttrs(el: Element, s: Stroke): void {
1240
+ const g = el.getAttribute('data-group-ids');
1241
+ if (g) {
1242
+ const ids = g.split(/\s+/).filter(Boolean);
1243
+ if (ids.length) s.groupIds = ids;
1244
+ }
1245
+ if (el.getAttribute('data-author') === 'ai') s.author = 'ai';
1246
+ // FigJam v3 — rotation. `data-rot` is authoritative (the transform attr is
1247
+ // presentational); malformed / out-of-range values drop to axis-aligned.
1248
+ const rotRaw = el.getAttribute('data-rot');
1249
+ if (rotRaw != null && canRotate(s)) {
1250
+ const rot = normalizeRotation(Number.parseFloat(rotRaw));
1251
+ if (rot !== 0) s.rotation = rot;
1252
+ }
1253
+ }
1254
+
1255
+ export function svgToStrokes(svgText: string): Stroke[] {
1256
+ const text = (svgText ?? '').trim();
1257
+ if (!text) return [];
1258
+ if (typeof DOMParser === 'undefined') return [];
1259
+ try {
1260
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
1261
+ if (doc.querySelector('parsererror')) return [];
1262
+ const out: Stroke[] = [];
1263
+ for (const el of Array.from(doc.querySelectorAll('[data-tool]'))) {
1264
+ const tool = el.getAttribute('data-tool');
1265
+ const id = el.getAttribute('data-id') || rid();
1266
+ const color = el.getAttribute('stroke') || el.getAttribute('fill') || DEFAULT_COLOR;
1267
+ const width = Number.parseFloat(el.getAttribute('stroke-width') || '2') || 2;
1268
+ // FigJam v3 — every branch funnels through push() so the shared attrs
1269
+ // (group ids / provenance) are read uniformly.
1270
+ const push = (s: Stroke): void => {
1271
+ readSharedAttrs(el, s);
1272
+ out.push(s);
1273
+ };
1274
+ if (tool === 'pen') {
1275
+ const d = el.getAttribute('d') || '';
1276
+ const points = parsePathD(d);
1277
+ if (points.length) {
1278
+ const pen: PenStroke = { id, tool: 'pen', color, width, points };
1279
+ // Highlighter flag; absent ⇒ undefined so a normal pen round-trips.
1280
+ if (el.getAttribute('data-highlighter') === '1') pen.highlighter = true;
1281
+ push(pen);
1282
+ }
1283
+ continue;
1284
+ }
1285
+ if (tool === 'sticky') {
1286
+ // Phase 21 — sticky reads geometry off its <rect> child, paper tint
1287
+ // off the group fill, body text off the inner <text>.
1288
+ const rectEl = el.querySelector('rect');
1289
+ const x = pfloat(rectEl?.getAttribute('x'));
1290
+ const y = pfloat(rectEl?.getAttribute('y'));
1291
+ const w = pfloat(rectEl?.getAttribute('width'));
1292
+ const h = pfloat(rectEl?.getAttribute('height'));
1293
+ const cornerRadius = pfloat(el.getAttribute('data-r'), STICKY_CORNER_RADIUS);
1294
+ const fontSize = pfloat(el.getAttribute('data-fs'), DEFAULT_FONT_SIZE) || DEFAULT_FONT_SIZE;
1295
+ const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
1296
+ const body = el.querySelector('text');
1297
+ const sticky: StickyStroke = {
1298
+ id,
1299
+ tool: 'sticky',
1300
+ color: stickyColor,
1301
+ x,
1302
+ y,
1303
+ w,
1304
+ h,
1305
+ text: body?.textContent ?? '',
1306
+ fontSize,
1307
+ cornerRadius,
1308
+ };
1309
+ // Style attrs; absent ⇒ defaults (normal / left), left unset.
1310
+ if (el.getAttribute('data-bold') === '1') sticky.bold = true;
1311
+ if (el.getAttribute('data-italic') === '1') sticky.italic = true;
1312
+ if (el.getAttribute('data-strike') === '1') sticky.strike = true;
1313
+ if (el.getAttribute('data-underline') === '1') sticky.underline = true;
1314
+ const sticAlign = el.getAttribute('data-align');
1315
+ if (sticAlign === 'left' || sticAlign === 'center' || sticAlign === 'right') {
1316
+ sticky.align = sticAlign;
1317
+ }
1318
+ const sticList = el.getAttribute('data-list');
1319
+ if (sticList === 'bullet' || sticList === 'number') sticky.listType = sticList;
1320
+ push(sticky);
1321
+ continue;
1322
+ }
1323
+ if (tool === 'section') {
1324
+ const rectEl = el.querySelector('rect');
1325
+ const x = pfloat(rectEl?.getAttribute('x'));
1326
+ const y = pfloat(rectEl?.getAttribute('y'));
1327
+ const w = pfloat(rectEl?.getAttribute('width'));
1328
+ const h = pfloat(rectEl?.getAttribute('height'));
1329
+ const section: SectionStroke = {
1330
+ id,
1331
+ tool: 'section',
1332
+ x,
1333
+ y,
1334
+ w,
1335
+ h,
1336
+ label: el.getAttribute('data-label') || 'Section',
1337
+ color: el.getAttribute('fill') || DEFAULT_SECTION_COLOR,
1338
+ };
1339
+ push(section);
1340
+ continue;
1341
+ }
1342
+ if (tool === 'rect') {
1343
+ const x = pfloat(el.getAttribute('x'));
1344
+ const y = pfloat(el.getAttribute('y'));
1345
+ const w = pfloat(el.getAttribute('width'));
1346
+ const h = pfloat(el.getAttribute('height'));
1347
+ const fill = parseFill(el.getAttribute('fill'));
1348
+ // Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
1349
+ const cornerRadius = pfloat(el.getAttribute('data-r'));
1350
+ const rect: RectStroke = { id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius };
1351
+ // Dashed (item 7); absent ⇒ undefined so a solid rect round-trips.
1352
+ const rectDash = el.getAttribute('data-dash');
1353
+ if (rectDash === '1' || rectDash === 'true') rect.dashed = true;
1354
+ push(rect);
1355
+ continue;
1356
+ }
1357
+ if (tool === 'ellipse') {
1358
+ const cx = pfloat(el.getAttribute('cx'));
1359
+ const cy = pfloat(el.getAttribute('cy'));
1360
+ const rx = pfloat(el.getAttribute('rx'));
1361
+ const ry = pfloat(el.getAttribute('ry'));
1362
+ const fill = parseFill(el.getAttribute('fill'));
1363
+ const ell: EllipseStroke = { id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill };
1364
+ const ellDash = el.getAttribute('data-dash');
1365
+ if (ellDash === '1' || ellDash === 'true') ell.dashed = true;
1366
+ push(ell);
1367
+ continue;
1368
+ }
1369
+ if (tool === 'polygon') {
1370
+ // Phase 24 — recover the bbox from the points; shape from data-shape.
1371
+ const shapeRaw = el.getAttribute('data-shape');
1372
+ const shape: PolygonShape =
1373
+ shapeRaw === 'triangle' || shapeRaw === 'triangle-down' ? shapeRaw : 'diamond';
1374
+ const bb = polygonBBox(el.getAttribute('points') || '');
1375
+ if (bb) {
1376
+ const fill = parseFill(el.getAttribute('fill'));
1377
+ const poly: PolygonStroke = {
1378
+ id,
1379
+ tool: 'polygon',
1380
+ shape,
1381
+ color,
1382
+ width,
1383
+ x: bb.x,
1384
+ y: bb.y,
1385
+ w: bb.w,
1386
+ h: bb.h,
1387
+ fill,
1388
+ };
1389
+ const dashRaw = el.getAttribute('data-dash');
1390
+ if (dashRaw === '1' || dashRaw === 'true') poly.dashed = true;
1391
+ push(poly);
1392
+ }
1393
+ continue;
1394
+ }
1395
+ if (tool === 'arrow') {
1396
+ // Phase 24 — shaft is a <line> (straight) OR a <path> (curved/elbow).
1397
+ // Recover the two endpoints from whichever is present.
1398
+ const ends = arrowEndpoints(el);
1399
+ if (ends) {
1400
+ const arrow: ArrowStroke = {
1401
+ id,
1402
+ tool: 'arrow',
1403
+ color,
1404
+ width,
1405
+ x1: ends.x1,
1406
+ y1: ends.y1,
1407
+ x2: ends.x2,
1408
+ y2: ends.y2,
1409
+ };
1410
+ // Heads + dash + line-type. The serializer writes a data-* attribute
1411
+ // only for a NON-default value, so a legacy arrow carries none of
1412
+ // these and stays { startHead/endHead/dashed/lineType: undefined } →
1413
+ // defaults on re-serialize (byte-identical, canary). Phase 24 widened
1414
+ // the head enum, so read the literal value rather than match a single
1415
+ // string.
1416
+ // Clamp to the known head vocabulary — an out-of-vocab / poisoned
1417
+ // value (hub-pushed SVG) is rejected, never cast through unchecked
1418
+ // (Phase 24 security review, DDR-067).
1419
+ const sh = el.getAttribute('data-start-head');
1420
+ if (sh && ARROW_HEADS.has(sh)) arrow.startHead = sh as ArrowHead;
1421
+ const eh = el.getAttribute('data-end-head');
1422
+ if (eh && ARROW_HEADS.has(eh)) arrow.endHead = eh as ArrowHead;
1423
+ const lt = el.getAttribute('data-line-type');
1424
+ if (lt === 'curved' || lt === 'elbow' || lt === 'straight') arrow.lineType = lt;
1425
+ const dashRaw = el.getAttribute('data-dash');
1426
+ if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
1427
+ // FigJam v3 — magnetic endpoint bindings (validated + clamped).
1428
+ const sb = parseBindAttr(el.getAttribute('data-start-bind'));
1429
+ if (sb) arrow.startBind = sb;
1430
+ const eb = parseBindAttr(el.getAttribute('data-end-bind'));
1431
+ if (eb) arrow.endBind = eb;
1432
+ push(arrow);
1433
+ }
1434
+ continue;
1435
+ }
1436
+ if (tool === 'image') {
1437
+ // Phase 23 — geometry off the element; href is whatever survived the
1438
+ // sanitizer (a valid assets/<sha8>.<ext> path, or '' if it was stripped
1439
+ // — an external/data:/`..` href is dropped server-side, so a poisoned
1440
+ // SVG round-trips to an inert empty-href stroke that fetches nothing).
1441
+ const x = pfloat(el.getAttribute('x'));
1442
+ const y = pfloat(el.getAttribute('y'));
1443
+ const w = pfloat(el.getAttribute('width'));
1444
+ const h = pfloat(el.getAttribute('height'));
1445
+ const href = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
1446
+ const img: ImageStroke = { id, tool: 'image', x, y, w, h, href };
1447
+ const alt = el.getAttribute('data-alt');
1448
+ if (alt) img.alt = alt;
1449
+ push(img);
1450
+ continue;
1451
+ }
1452
+ if (tool === 'link') {
1453
+ // Phase 23 — data-* are the source of truth; geometry off the <rect>
1454
+ // child (mirrors sticky). Defensive: missing title ⇒ domain.
1455
+ const rectEl = el.querySelector('rect');
1456
+ const x = pfloat(rectEl?.getAttribute('x'));
1457
+ const y = pfloat(rectEl?.getAttribute('y'));
1458
+ const w = pfloat(rectEl?.getAttribute('width'), LINK_DEFAULT_W);
1459
+ const h = pfloat(rectEl?.getAttribute('height'), LINK_DEFAULT_H);
1460
+ const url = el.getAttribute('data-url') || '';
1461
+ const domain = el.getAttribute('data-domain') || '';
1462
+ const title = el.getAttribute('data-title') || domain || url;
1463
+ push({ id, tool: 'link', x, y, w, h, url, title, domain });
1464
+ continue;
1465
+ }
1466
+ if (tool === 'text') {
1467
+ const rawAnchor = el.getAttribute('data-anchor-id');
1468
+ const fontSize =
1469
+ pfloat(el.getAttribute('data-font-size'), DEFAULT_FONT_SIZE) || DEFAULT_FONT_SIZE;
1470
+ const inkColor = el.getAttribute('fill') || color;
1471
+ // List style (item 4c) — read FIRST so per-line markers can be stripped
1472
+ // off the parsed text (markers are render-only; never stored).
1473
+ const listRaw = el.getAttribute('data-list');
1474
+ const listType: ListType | undefined =
1475
+ listRaw === 'bullet' || listRaw === 'number' ? listRaw : undefined;
1476
+ // Multi-line text (item 4a) — one <tspan> per line. Recover `\n` by
1477
+ // joining tspan text content (markers stripped); a legacy single-run
1478
+ // <text> has no tspans → read its trimmed textContent.
1479
+ const tspans = el.querySelectorAll('tspan');
1480
+ const body =
1481
+ tspans.length > 0
1482
+ ? Array.from(tspans)
1483
+ .map((t, i) => stripListPrefix(t.textContent ?? '', i, listType))
1484
+ .join('\n')
1485
+ : stripListPrefix((el.textContent || '').trim(), 0, listType);
1486
+ // bold / italic / strike / underline / align. `data-align` is the
1487
+ // round-trip source of truth (text-anchor is derived from it). Absent ⇒
1488
+ // default (normal / per-kind align), left unset so legacy nodes
1489
+ // round-trip.
1490
+ const isBold = el.getAttribute('font-weight') === '700';
1491
+ const isItalic = el.getAttribute('font-style') === 'italic';
1492
+ const decoAttr = el.getAttribute('text-decoration') || '';
1493
+ const isStrike = decoAttr.includes('line-through');
1494
+ const isUnderline = decoAttr.includes('underline');
1495
+ const da = el.getAttribute('data-align');
1496
+ const align: TextAlign | undefined =
1497
+ da === 'left' || da === 'center' || da === 'right' ? da : undefined;
1498
+ // Phase 21 — standalone text (no data-anchor-id) carries world x/y
1499
+ // instead of a host id.
1500
+ if (!rawAnchor) {
1501
+ const t: TextStroke = {
1502
+ id,
1503
+ tool: 'text',
1504
+ color: inkColor,
1505
+ fontSize,
1506
+ text: body,
1507
+ x: pfloat(el.getAttribute('x')),
1508
+ y: pfloat(el.getAttribute('y')),
1509
+ };
1510
+ if (isBold) t.bold = true;
1511
+ if (isItalic) t.italic = true;
1512
+ if (isStrike) t.strike = true;
1513
+ if (isUnderline) t.underline = true;
1514
+ if (listType) t.listType = listType;
1515
+ if (align) t.align = align;
1516
+ push(t);
1517
+ continue;
1518
+ }
1519
+ const t: TextStroke = {
1520
+ id,
1521
+ tool: 'text',
1522
+ color: inkColor,
1523
+ fontSize,
1524
+ text: body,
1525
+ anchorId: rawAnchor,
1526
+ };
1527
+ if (isBold) t.bold = true;
1528
+ if (isItalic) t.italic = true;
1529
+ if (isStrike) t.strike = true;
1530
+ if (isUnderline) t.underline = true;
1531
+ if (listType) t.listType = listType;
1532
+ if (align) t.align = align;
1533
+ push(t);
1534
+ }
1535
+ }
1536
+ return out;
1537
+ } catch {
1538
+ return [];
1539
+ }
1540
+ }
1541
+
1542
+ // ─────────────────────────────────────────────────────────────────────────────
1543
+ // Geometry — hit-test, bbox, normalize, translate
1544
+
1545
+ function pointSegmentDist(
1546
+ px: number,
1547
+ py: number,
1548
+ ax: number,
1549
+ ay: number,
1550
+ bx: number,
1551
+ by: number
1552
+ ): number {
1553
+ const dx = bx - ax;
1554
+ const dy = by - ay;
1555
+ const len2 = dx * dx + dy * dy;
1556
+ if (len2 === 0) return Math.hypot(px - ax, py - ay);
1557
+ let t = ((px - ax) * dx + (py - ay) * dy) / len2;
1558
+ t = Math.max(0, Math.min(1, t));
1559
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1560
+ }
1561
+
1562
+ /** FigJam v3 — strokes whose `rotation` is honoured (see StrokeBase doc). */
1563
+ export function canRotate(s: Stroke): boolean {
1564
+ if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'section') return false;
1565
+ if (s.tool === 'text') return s.anchorId == null || s.anchorId === '';
1566
+ return true;
1567
+ }
1568
+
1569
+ /** Normalize degrees into (-180, 180]; non-finite → 0. */
1570
+ export function normalizeRotation(deg: number): number {
1571
+ if (!Number.isFinite(deg)) return 0;
1572
+ let d = deg % 360;
1573
+ if (d > 180) d -= 360;
1574
+ if (d <= -180) d += 360;
1575
+ // Quantize to 0.1° — keeps the serialized form short + float-stable.
1576
+ return Math.round(d * 10) / 10;
1577
+ }
1578
+
1579
+ /** The stroke's effective rotation (0 for tools that ignore it). */
1580
+ export function strokeRotation(s: Stroke): number {
1581
+ if (!s.rotation || !canRotate(s)) return 0;
1582
+ return normalizeRotation(s.rotation);
1583
+ }
1584
+
1585
+ /** Rotate a point around a pivot by `deg` degrees clockwise. */
1586
+ export function rotatePoint(
1587
+ px: number,
1588
+ py: number,
1589
+ cx: number,
1590
+ cy: number,
1591
+ deg: number
1592
+ ): [number, number] {
1593
+ const rad = (deg * Math.PI) / 180;
1594
+ const cos = Math.cos(rad);
1595
+ const sin = Math.sin(rad);
1596
+ const dx = px - cx;
1597
+ const dy = py - cy;
1598
+ return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
1599
+ }
1600
+
1601
+ /** The stroke's (unrotated) bbox center — the rotation pivot. */
1602
+ export function strokeCenter(s: Stroke): [number, number] | null {
1603
+ const bb = strokeBBox(s);
1604
+ if (!bb) return null;
1605
+ return [bb.x + bb.w / 2, bb.y + bb.h / 2];
1606
+ }
1607
+
1608
+ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
1609
+ // FigJam v3 — rotated strokes hit-test in their LOCAL frame: inverse-rotate
1610
+ // the probe around the pivot, then run the axis-aligned math unchanged.
1611
+ const rot = strokeRotation(s);
1612
+ if (rot !== 0) {
1613
+ const c = strokeCenter(s);
1614
+ if (c) {
1615
+ const [ux, uy] = rotatePoint(wx, wy, c[0], c[1], -rot);
1616
+ wx = ux;
1617
+ wy = uy;
1618
+ }
1619
+ }
1620
+ if (s.tool === 'text') {
1621
+ // Anchored text isn't independently hit-testable (it inherits its host).
1622
+ // Standalone text (Phase 21) uses its synthetic bbox so the eraser can
1623
+ // reach it.
1624
+ if (s.anchorId != null && s.anchorId !== '') return false;
1625
+ const bb = strokeBBox(s);
1626
+ if (!bb) return false;
1627
+ return (
1628
+ wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
1629
+ );
1630
+ }
1631
+ if (s.tool === 'section') {
1632
+ // FigJam — a section is grabbed by its BORDER or its label chip; the
1633
+ // interior stays click-through so content on the section selects normally.
1634
+ const t = Math.max(tol, 8);
1635
+ const xMin = Math.min(s.x, s.x + s.w);
1636
+ const xMax = Math.max(s.x, s.x + s.w);
1637
+ const yMin = Math.min(s.y, s.y + s.h);
1638
+ const yMax = Math.max(s.y, s.y + s.h);
1639
+ // Label chip above the top-left corner.
1640
+ const chipW = Math.max(56, s.label.length * SECTION_LABEL_FONT * 0.62 + 18);
1641
+ if (wx >= xMin && wx <= xMin + chipW && wy >= yMin - SECTION_LABEL_H - 6 && wy <= yMin) {
1642
+ return true;
1643
+ }
1644
+ if (wx < xMin - t || wx > xMax + t || wy < yMin - t || wy > yMax + t) return false;
1645
+ const onEdge =
1646
+ Math.abs(wx - xMin) <= t ||
1647
+ Math.abs(wx - xMax) <= t ||
1648
+ Math.abs(wy - yMin) <= t ||
1649
+ Math.abs(wy - yMax) <= t;
1650
+ return onEdge;
1651
+ }
1652
+ const t = Math.max(tol, 'width' in s ? s.width : 2);
1653
+ if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
1654
+ // Sticky / image / link are solid cards — filled-rect hit anywhere inside.
1655
+ const xMin = Math.min(s.x, s.x + s.w);
1656
+ const xMax = Math.max(s.x, s.x + s.w);
1657
+ const yMin = Math.min(s.y, s.y + s.h);
1658
+ const yMax = Math.max(s.y, s.y + s.h);
1659
+ return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
1660
+ }
1661
+ if (s.tool === 'pen') {
1662
+ if (s.points.length === 1) {
1663
+ const p = s.points[0] as WorldPoint;
1664
+ return Math.hypot(wx - p[0], wy - p[1]) <= t;
1665
+ }
1666
+ for (let i = 1; i < s.points.length; i++) {
1667
+ const a = s.points[i - 1] as WorldPoint;
1668
+ const b = s.points[i] as WorldPoint;
1669
+ if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
1670
+ }
1671
+ return false;
1672
+ }
1673
+ if (s.tool === 'arrow') {
1674
+ return pointSegmentDist(wx, wy, s.x1, s.y1, s.x2, s.y2) <= t;
1675
+ }
1676
+ if (s.tool === 'ellipse') {
1677
+ // Inside-ellipse hit when filled; on the perimeter otherwise.
1678
+ if (s.rx <= 0 || s.ry <= 0) return false;
1679
+ const nx = (wx - s.cx) / s.rx;
1680
+ const ny = (wy - s.cy) / s.ry;
1681
+ const d = nx * nx + ny * ny;
1682
+ if (s.fill) return d <= 1.0 + t / Math.max(s.rx, s.ry);
1683
+ // Stroke-only: hit if normalized distance is within a band around 1.
1684
+ const band = t / Math.max(s.rx, s.ry);
1685
+ const dist = Math.abs(Math.sqrt(d) - 1);
1686
+ return dist <= band;
1687
+ }
1688
+ if (s.tool === 'polygon') {
1689
+ const nx = Math.min(s.x, s.x + s.w);
1690
+ const ny = Math.min(s.y, s.y + s.h);
1691
+ const pts = polygonVertices(s.shape, nx, ny, Math.abs(s.w), Math.abs(s.h));
1692
+ // Filled → inside-hit; always allow an edge-proximity hit (covers the
1693
+ // stroke-only outline + a tolerance band on a filled shape).
1694
+ if (s.fill && pointInPolygon(wx, wy, pts)) return true;
1695
+ for (let i = 0; i < pts.length; i++) {
1696
+ const a = pts[i] as [number, number];
1697
+ const b = pts[(i + 1) % pts.length] as [number, number];
1698
+ if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
1699
+ }
1700
+ return false;
1701
+ }
1702
+ // rect — inside when filled, edge-only otherwise.
1703
+ const x = s.x;
1704
+ const y = s.y;
1705
+ const x2 = x + s.w;
1706
+ const y2 = y + s.h;
1707
+ const xMin = Math.min(x, x2);
1708
+ const xMax = Math.max(x, x2);
1709
+ const yMin = Math.min(y, y2);
1710
+ const yMax = Math.max(y, y2);
1711
+ if (s.fill) {
1712
+ return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
1713
+ }
1714
+ if (wx < xMin - t || wx > xMax + t) return false;
1715
+ if (wy < yMin - t || wy > yMax + t) return false;
1716
+ const onLeft = Math.abs(wx - x) <= t;
1717
+ const onRight = Math.abs(wx - x2) <= t;
1718
+ const onTop = Math.abs(wy - y) <= t;
1719
+ const onBottom = Math.abs(wy - y2) <= t;
1720
+ return onLeft || onRight || onTop || onBottom;
1721
+ }
1722
+
1723
+ /** Flip a negative-extent box so x/y is the top-left and w/h are positive. */
1724
+ export function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(r: T): T {
1725
+ if (r.w >= 0 && r.h >= 0) return r;
1726
+ return {
1727
+ ...r,
1728
+ x: Math.min(r.x, r.x + r.w),
1729
+ y: Math.min(r.y, r.y + r.h),
1730
+ w: Math.abs(r.w),
1731
+ h: Math.abs(r.h),
1732
+ };
1733
+ }
1734
+
1735
+ /**
1736
+ * Phase 24 — draw-time resize modifiers (FigJam parity, mirror of the
1737
+ * `use-annotation-resize.tsx` set so create + resize feel identical):
1738
+ * • `shift` — lock to 1:1 (square / circle); the larger drag axis sets the
1739
+ * side, each axis keeps its own sign so the drag direction holds.
1740
+ * • `alt` — grow from the pointer-down point as CENTER (symmetric).
1741
+ * With neither held the box is `{ x: down, w: cursor − down }` — byte-identical
1742
+ * to the previous corner-drag math.
1743
+ */
1744
+ export interface DrawMods {
1745
+ shift: boolean;
1746
+ alt: boolean;
1747
+ }
1748
+
1749
+ /** Constrain a draw drag (`ax,ay` = pointer-down anchor; `wx,wy` = cursor). */
1750
+ export function constrainDrawBox(
1751
+ ax: number,
1752
+ ay: number,
1753
+ wx: number,
1754
+ wy: number,
1755
+ mods: DrawMods
1756
+ ): { x: number; y: number; w: number; h: number } {
1757
+ let dx = wx - ax;
1758
+ let dy = wy - ay;
1759
+ if (mods.shift) {
1760
+ const side = Math.max(Math.abs(dx), Math.abs(dy));
1761
+ dx = (dx < 0 ? -1 : 1) * side;
1762
+ dy = (dy < 0 ? -1 : 1) * side;
1763
+ }
1764
+ if (mods.alt) {
1765
+ // Anchor is the center → span ±|d| on each axis around it.
1766
+ return { x: ax - dx, y: ay - dy, w: 2 * dx, h: 2 * dy };
1767
+ }
1768
+ return { x: ax, y: ay, w: dx, h: dy };
1769
+ }
1770
+
1771
+ /**
1772
+ * Apply the draw-time modifiers to the in-progress stroke. Shared by the
1773
+ * pointer-move handler and the live keydown/keyup re-apply, so holding Shift /
1774
+ * Alt updates the draft even without moving the cursor. `anchor` is the
1775
+ * pointer-down point; pen / text carry no box so they pass through unchanged.
1776
+ */
1777
+ export function applyDrawModifiers(
1778
+ cur: Stroke,
1779
+ anchor: { x: number; y: number },
1780
+ wx: number,
1781
+ wy: number,
1782
+ mods: DrawMods
1783
+ ): Stroke {
1784
+ if (cur.tool === 'rect' || cur.tool === 'polygon' || cur.tool === 'section') {
1785
+ const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
1786
+ return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
1787
+ }
1788
+ if (cur.tool === 'ellipse') {
1789
+ const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
1790
+ return {
1791
+ ...cur,
1792
+ cx: b.x + b.w / 2,
1793
+ cy: b.y + b.h / 2,
1794
+ rx: Math.abs(b.w) / 2,
1795
+ ry: Math.abs(b.h) / 2,
1796
+ };
1797
+ }
1798
+ if (cur.tool === 'sticky') {
1799
+ // Stickies are always 1:1 — force the square constraint; Alt still centers.
1800
+ const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, { shift: true, alt: mods.alt });
1801
+ return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
1802
+ }
1803
+ if (cur.tool === 'arrow') {
1804
+ let x2 = wx;
1805
+ let y2 = wy;
1806
+ if (mods.shift) {
1807
+ // Snap the shaft to the nearest 45° around the anchor (its midpoint
1808
+ // under Alt), keeping the cursor's distance.
1809
+ const dx = wx - anchor.x;
1810
+ const dy = wy - anchor.y;
1811
+ const dist = Math.hypot(dx, dy);
1812
+ const step = Math.PI / 4;
1813
+ const ang = Math.round(Math.atan2(dy, dx) / step) * step;
1814
+ x2 = anchor.x + Math.cos(ang) * dist;
1815
+ y2 = anchor.y + Math.sin(ang) * dist;
1816
+ }
1817
+ if (mods.alt) {
1818
+ // Anchor is the midpoint → the start end mirrors the dragged end.
1819
+ return { ...cur, x1: 2 * anchor.x - x2, y1: 2 * anchor.y - y2, x2, y2 };
1820
+ }
1821
+ return { ...cur, x1: anchor.x, y1: anchor.y, x2, y2 };
1822
+ }
1823
+ return cur;
1824
+ }
1825
+
1826
+ export function normalizeRect(r: RectStroke): RectStroke {
1827
+ return normalizeBox(r);
1828
+ }
1829
+
1830
+ // Phase 21 — sticky shares rect's drag-to-create flip (x = min, w = abs(w)).
1831
+ export function normalizeSticky(s: StickyStroke): StickyStroke {
1832
+ return normalizeBox(s);
1833
+ }
1834
+
1835
+ export function isStrokeMeaningful(s: Stroke): boolean {
1836
+ if (s.tool === 'pen') return s.points.length >= 2;
1837
+ if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
1838
+ if (s.tool === 'polygon') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
1839
+ if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
1840
+ if (s.tool === 'text') return s.text.trim().length > 0;
1841
+ // Sticky below a readable floor is discarded like a 2×2 rect.
1842
+ if (s.tool === 'sticky')
1843
+ return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
1844
+ // Phase 23 — an image needs real extent; a link needs a non-empty URL.
1845
+ if (s.tool === 'image') return Math.abs(s.w) >= IMAGE_MIN_SIZE && Math.abs(s.h) >= IMAGE_MIN_SIZE;
1846
+ if (s.tool === 'link') return s.url.trim().length > 0;
1847
+ if (s.tool === 'section')
1848
+ return Math.abs(s.w) >= SECTION_MIN_SIZE && Math.abs(s.h) >= SECTION_MIN_SIZE;
1849
+ return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
1850
+ }
1851
+
1852
+ export function strokeBBox(
1853
+ s: Stroke,
1854
+ anchors?: Map<string, AnchorHost>
1855
+ ): { x: number; y: number; w: number; h: number } | null {
1856
+ if (s.tool === 'pen') {
1857
+ if (!s.points.length) return null;
1858
+ let xMin = Number.POSITIVE_INFINITY;
1859
+ let xMax = Number.NEGATIVE_INFINITY;
1860
+ let yMin = Number.POSITIVE_INFINITY;
1861
+ let yMax = Number.NEGATIVE_INFINITY;
1862
+ for (const [px, py] of s.points) {
1863
+ if (px < xMin) xMin = px;
1864
+ if (px > xMax) xMax = px;
1865
+ if (py < yMin) yMin = py;
1866
+ if (py > yMax) yMax = py;
1867
+ }
1868
+ return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
1869
+ }
1870
+ if (s.tool === 'rect' || s.tool === 'polygon') {
1871
+ return {
1872
+ x: Math.min(s.x, s.x + s.w),
1873
+ y: Math.min(s.y, s.y + s.h),
1874
+ w: Math.abs(s.w),
1875
+ h: Math.abs(s.h),
1876
+ };
1877
+ }
1878
+ if (s.tool === 'ellipse') {
1879
+ return { x: s.cx - s.rx, y: s.cy - s.ry, w: s.rx * 2, h: s.ry * 2 };
1880
+ }
1881
+ if (s.tool === 'arrow') {
1882
+ return {
1883
+ x: Math.min(s.x1, s.x2),
1884
+ y: Math.min(s.y1, s.y2),
1885
+ w: Math.abs(s.x2 - s.x1),
1886
+ h: Math.abs(s.y2 - s.y1),
1887
+ };
1888
+ }
1889
+ if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link' || s.tool === 'section') {
1890
+ // Phase 23 — image + link are rect-shaped media, same bbox as a sticky
1891
+ // card; FigJam v3 sections share it too.
1892
+ return {
1893
+ x: Math.min(s.x, s.x + s.w),
1894
+ y: Math.min(s.y, s.y + s.h),
1895
+ w: Math.abs(s.w),
1896
+ h: Math.abs(s.h),
1897
+ };
1898
+ }
1899
+ // text — anchored inherits its host's bbox; standalone (Phase 21) gets a
1900
+ // synthetic bbox from its world (x, y) so it's selectable and the context
1901
+ // toolbar can position against it.
1902
+ if (s.anchorId != null && s.anchorId !== '') {
1903
+ const host = anchors?.get(s.anchorId);
1904
+ return host ? strokeBBox(host) : null;
1905
+ }
1906
+ const tx = s.x ?? 0;
1907
+ const ty = s.y ?? 0;
1908
+ // Multi-line text (item 4a) — the bbox spans the longest line (width) and all
1909
+ // lines (height) so the selection halo / hit-test / eraser cover the whole
1910
+ // block, not just line one. List markers widen each line by 2–3 chars.
1911
+ const lines = splitTextLines(s.text);
1912
+ const markerPad = s.listType ? 3 : 0;
1913
+ const longest = lines.reduce((m, l) => Math.max(m, l.length + markerPad), 0);
1914
+ return {
1915
+ x: tx,
1916
+ y: ty,
1917
+ w: Math.max(8, longest * s.fontSize * 0.55),
1918
+ // Single-line keeps the legacy 1.2 height; multi-line grows by line count.
1919
+ h: lines.length <= 1 ? s.fontSize * 1.2 : lines.length * s.fontSize * TEXT_LINE_HEIGHT,
1920
+ };
1921
+ }
1922
+
1923
+ export function translateOne(s: Stroke, dx: number, dy: number): Stroke {
1924
+ if (s.tool === 'pen') {
1925
+ return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
1926
+ }
1927
+ if (s.tool === 'rect' || s.tool === 'polygon') return { ...s, x: s.x + dx, y: s.y + dy };
1928
+ if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
1929
+ if (s.tool === 'arrow')
1930
+ return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
1931
+ if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link' || s.tool === 'section')
1932
+ return { ...s, x: s.x + dx, y: s.y + dy };
1933
+ // text — anchored inherits its host's bbox (moves with the host); standalone
1934
+ // (Phase 21) carries its own world (x, y) and translates directly.
1935
+ if (s.anchorId != null && s.anchorId !== '') return s;
1936
+ return { ...s, x: (s.x ?? 0) + dx, y: (s.y ?? 0) + dy };
1937
+ }
1938
+
1939
+ /**
1940
+ * Reference-equal stroke comparison — true when the two arrays carry the same
1941
+ * stroke object references in the same order. Used by the annotation drag
1942
+ * onPointerUp to skip pushing an undo record when the gesture didn't actually
1943
+ * move anything (zero movement OR snapshot mapped through a no-op translate
1944
+ * back to the original references — `translateOne` short-circuits when dx=dy=0
1945
+ * because new objects are still created, so we compare references defensively).
1946
+ */
1947
+ export function strokesShallowEqual(a: readonly Stroke[], b: readonly Stroke[]): boolean {
1948
+ if (a === b) return true;
1949
+ if (a.length !== b.length) return false;
1950
+ for (let i = 0; i < a.length; i++) {
1951
+ if (a[i] !== b[i]) return false;
1952
+ }
1953
+ return true;
1954
+ }
1955
+
1956
+ // ─────────────────────────────────────────────────────────────────────────────
1957
+ // Sanitizer — moved from api.ts (FigJam v3): the allowlist guards exactly the
1958
+ // vocabulary this module serializes, and headless consumers (the `maude design
1959
+ // annotate` write verb) need it without pulling the server modules.
1960
+
1961
+ /**
1962
+ * Elements the annotation tool legitimately emits (`strokesToSvg` in
1963
+ * annotations-layer.tsx). This is the WHOLE vocabulary — purely presentational.
1964
+ */
1965
+ export const ANNOTATION_SVG_ELEMENTS = new Set([
1966
+ 'svg',
1967
+ 'g',
1968
+ 'path',
1969
+ 'rect',
1970
+ 'ellipse',
1971
+ 'line',
1972
+ 'polyline',
1973
+ // Phase 24 — `polygon` (diamond / triangle shape primitives + closed arrowhead
1974
+ // outlines + diamond heads) and `circle` (circle arrowhead). Inert shape
1975
+ // elements with no script capability — same safety class as the rest.
1976
+ 'polygon',
1977
+ 'circle',
1978
+ 'text',
1979
+ // Annotation polish (item 4a) — `tspan` carries one line of multi-line text
1980
+ // inside a `<text>` (SVG `<text>` ignores `\n`). Inert presentational element
1981
+ // with no script capability — same safety class as `text`. Only `x`/`dy`
1982
+ // (geometry) survive; the attribute denylist strips any on*/style/href.
1983
+ 'tspan',
1984
+ // Phase 23 — `image` (dropped/pasted raster). The ONLY element allowed to keep
1985
+ // an href, and ONLY a relative assets/<sha8>.<ext> path (ASSET_IMAGE_HREF_RE) —
1986
+ // every external / data: / javascript: / `..` href is still stripped. <image>
1987
+ // is a passive include with no script capability. See DDR (Task 9).
1988
+ 'image',
1989
+ ]);
1990
+
1991
+ /**
1992
+ * Phase 23 — the ONLY href shape allowed to survive on an `<image>`: a relative,
1993
+ * single-segment `assets/<name>.<ext>` path. Anchored (`^…$`), no scheme, no `/`
1994
+ * beyond the one `assets/` segment (blocks `assets/../../etc/passwd`), no query,
1995
+ * ext ∈ the four raster types the upload route accepts. Everything else —
1996
+ * external, `data:`, `javascript:`, `..`, or any href on a non-image element —
1997
+ * is stripped by Rule 3. Pairs with the asset-write caps (DDR Task 9).
1998
+ */
1999
+ export const ASSET_IMAGE_HREF_RE = /^assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif)$/;
2000
+
2001
+ /**
2002
+ * A3 (DDR-060 F1 re-audit) — sanitize an annotation SVG before it is persisted /
2003
+ * synced / mirrored into the collab Y.Map. ALLOWLIST, not denylist (the F1
2004
+ * confirming pass showed a denylist loses the race: `<svg:script>` via namespace,
2005
+ * `javascript&#58;` via HTML entity, `<style>@import url()>`, CSS `url(javascript:)`
2006
+ * all slipped a tag-name denylist). Two rules, both keyed to the fixed legit
2007
+ * vocabulary so they're zero-regression on real annotations:
2008
+ *
2009
+ * 1. Element allowlist — drop the markup of any tag whose LOCAL name (namespace
2010
+ * stripped, so `svg:script` → `script`) isn't in ANNOTATION_SVG_ELEMENTS.
2011
+ * Dropped-tag text content survives as inert text (not in a script/style
2012
+ * context), which is harmless.
2013
+ * 2. Attribute denylist on survivors — the legit vocabulary uses none of
2014
+ * `on*=`, `style=`, or any `href`, so stripping them closes inline handlers,
2015
+ * CSS `url(javascript:)`, and entity-encoded `xlink:href` in one pass.
2016
+ *
2017
+ * The current consumer (`svgToStrokes` → DOMParser image/svg+xml → structured
2018
+ * strokes → React) never raw-renders this string, so the live XSS risk is already
2019
+ * nil; this keeps "inert" TRUE for any future raw-render consumer and for the
2020
+ * synced file a peer / Claude-context ingests (defense-in-depth, DDR-054 §3).
2021
+ */
2022
+ export function sanitizeAnnotationSvg(svg: string): string {
2023
+ // Phase 23 — <image> href handling. Rule 3 below strips EVERY href (that's the
2024
+ // zero-bypass invariant). To let a legit assets/ href survive WITHOUT
2025
+ // re-opening the denylist race, we (a) neutralize any input-supplied marker so
2026
+ // only this pre-pass can mint one, (b) on `<image>` ONLY, hoist a regex-valid
2027
+ // assets href into a sanitizer-inert `data-mdcc-asset` marker (an external /
2028
+ // data: / `..` / non-image href is left as a plain href → stripped by Rule 3),
2029
+ // then (c) after the three rules, restore the marker back to `href` — but ONLY
2030
+ // after RE-validating the value, so a forged marker can never smuggle a
2031
+ // scheme/traversal back in. The restored value is, by construction, a safe
2032
+ // same-origin path. See ASSET_IMAGE_HREF_RE + DDR (Task 9).
2033
+ const hoisted = svg
2034
+ .replace(/\sdata-mdcc-asset\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
2035
+ .replace(/<\s*(?:[\w-]+:)?image\b[^>]*>/gi, (tag) => {
2036
+ const m = tag.match(/(?:xlink:)?href\s*=\s*"([^"]*)"|(?:xlink:)?href\s*=\s*'([^']*)'/i);
2037
+ const val = m ? (m[1] ?? m[2] ?? '') : '';
2038
+ if (!val || !ASSET_IMAGE_HREF_RE.test(val)) return tag;
2039
+ return tag.replace(/(?:xlink:)?href\s*=\s*("[^"]*"|'[^']*')/i, `data-mdcc-asset="${val}"`);
2040
+ });
2041
+ const cleaned = hoisted
2042
+ // 1. Remove the CONTENT of executable / instruction-bearing elements (not
2043
+ // just their tags) so an injected script body / `@import` / prompt-
2044
+ // injection string can't survive as inert text a future raw-renderer or
2045
+ // Claude-context read might act on. Namespace-tolerant (`svg:script`),
2046
+ // non-greedy to the first matching dangerous close tag.
2047
+ .replace(
2048
+ /<\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\b[\s\S]*?<\s*\/\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\s*>/gi,
2049
+ ''
2050
+ )
2051
+ // 2. Element allowlist — drop the markup of any tag whose LOCAL name isn't
2052
+ // in the fixed annotation vocabulary. `[^>]*` stops at the first `>`;
2053
+ // annotation attrs never contain a literal `>`.
2054
+ .replace(/<\/?\s*([a-zA-Z][\w:-]*)\b[^>]*>/g, (match, rawName: string) => {
2055
+ const local = String(rawName).split(':').pop()?.toLowerCase() ?? '';
2056
+ return ANNOTATION_SVG_ELEMENTS.has(local) ? match : '';
2057
+ })
2058
+ // 3. Attribute denylist on the surviving allowlisted elements — the legit
2059
+ // vocabulary uses no on*= / style= / *href=, so stripping them closes
2060
+ // inline handlers, CSS url(javascript:), and entity-encoded hrefs.
2061
+ // The leading boundary is a LOOKBEHIND on whitespace / quote / slash
2062
+ // (not a consumed `\s`) so a handler glued to the previous attribute's
2063
+ // closing quote — `<circle r="2"onload="…"/>`, which HTML/SVG parsers
2064
+ // accept as a distinct attribute — is also stripped (Phase 24 security
2065
+ // review, DDR-067). The non-consuming lookbehind leaves the preceding
2066
+ // quote intact so the legit attribute it belonged to survives.
2067
+ .replace(
2068
+ /(?<=[\s"'/])(?:on[a-z]+|style|(?:[\w-]+:)?href)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi,
2069
+ ''
2070
+ );
2071
+ // Post-pass — restore the validated assets href on <image>. Re-validate the
2072
+ // marker value (defense-in-depth: a forged data-mdcc-asset can only resolve to
2073
+ // a safe same-origin assets path; anything with a scheme/traversal is dropped).
2074
+ return cleaned.replace(/\sdata-mdcc-asset\s*=\s*"([^"]*)"/gi, (_whole, val: string) =>
2075
+ ASSET_IMAGE_HREF_RE.test(val) ? ` href="${val}"` : ''
2076
+ );
2077
+ }