@zombie-mermaid/svg-renderer 2.2.1

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.
@@ -0,0 +1,546 @@
1
+ import type {
2
+ PositionedSequenceDiagram,
3
+ PositionedActor,
4
+ Lifeline,
5
+ PositionedMessage,
6
+ Activation,
7
+ PositionedBlock,
8
+ PositionedNote,
9
+ PositionedParticipantBox,
10
+ } from '@zombie-mermaid/mermaid-parser'
11
+ import { boxLabelHeight } from './layout.ts'
12
+ import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
13
+ import {
14
+ svgOpenTag,
15
+ buildStyleBlock,
16
+ renderMultilineText,
17
+ escapeAttr,
18
+ } from '@zombie-mermaid/core'
19
+ import { withDataSrc } from '../renderer.ts'
20
+ import {
21
+ FONT_SIZES,
22
+ FONT_WEIGHTS,
23
+ STROKE_WIDTHS,
24
+ ARROW_HEAD,
25
+ estimateTextWidth,
26
+ } from '../styles.ts'
27
+ import type { FontSizes } from '../styles.ts'
28
+
29
+ // ============================================================================
30
+ // Sequence diagram SVG renderer
31
+ //
32
+ // Renders a positioned sequence diagram to SVG string.
33
+ // All colors use CSS custom properties (var(--_xxx)) from the theme system.
34
+ //
35
+ // Render order (back to front):
36
+ // 0. Participant-group backgrounds (box … end)
37
+ // 1. Block backgrounds (loop/alt/opt)
38
+ // 2. Lifelines (dashed vertical lines)
39
+ // 3. Activation boxes
40
+ // 4. Messages (arrows with labels)
41
+ // 5. Notes
42
+ // 6. Actor boxes (at top)
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Render a positioned sequence diagram as an SVG string.
47
+ *
48
+ * @param colors - DiagramColors with bg/fg and optional enrichment variables.
49
+ * @param transparent - If true, renders with transparent background.
50
+ * @param embedSource - Original diagram source to stamp onto the root `<svg>`
51
+ * as `data-src` (from `options.embedSource`). Omitted
52
+ * when the option is off.
53
+ * @param title - Accessible name (from `options.title`). See svgOpenTag() in
54
+ * packages/core/src/theme.ts.
55
+ * @param decorative - Marks the SVG decorative (from `options.decorative`).
56
+ * @param emit - Strict-CSP controls (from `options.nonce` /
57
+ * `options.styleAttribute`, see #216). Default: no nonce,
58
+ * root `style` attribute on.
59
+ */
60
+ export function renderSequenceSvg(
61
+ diagram: PositionedSequenceDiagram,
62
+ colors: DiagramColors,
63
+ font: string = 'Inter',
64
+ transparent: boolean = false,
65
+ fontSizes: FontSizes = FONT_SIZES,
66
+ embedSource?: string,
67
+ title?: string,
68
+ decorative?: boolean,
69
+ emit: SvgEmitOptions = {},
70
+ ): string {
71
+ const parts: string[] = []
72
+
73
+ // SVG root with CSS variables + style block + defs
74
+ parts.push(
75
+ withDataSrc(
76
+ svgOpenTag(
77
+ diagram.width,
78
+ diagram.height,
79
+ colors,
80
+ transparent,
81
+ title,
82
+ decorative,
83
+ undefined,
84
+ emit.styleAttribute,
85
+ ),
86
+ embedSource,
87
+ ),
88
+ )
89
+ parts.push(buildStyleBlock(font, false, emit.nonce))
90
+ parts.push('<defs>')
91
+
92
+ // Arrow marker definitions
93
+ parts.push(arrowMarkerDefs())
94
+ parts.push('</defs>')
95
+
96
+ // 0. Participant-group backgrounds (box … end), behind everything
97
+ for (const box of diagram.boxes) {
98
+ parts.push(renderParticipantBox(box, fontSizes))
99
+ }
100
+
101
+ // 1. Block backgrounds (loop/alt/opt rectangles)
102
+ for (const block of diagram.blocks) {
103
+ parts.push(renderBlock(block, fontSizes))
104
+ }
105
+
106
+ // 2. Lifelines (dashed vertical lines from actor to bottom)
107
+ for (const lifeline of diagram.lifelines) {
108
+ parts.push(renderLifeline(lifeline))
109
+ }
110
+
111
+ // 3. Activation boxes
112
+ for (const activation of diagram.activations) {
113
+ parts.push(renderActivation(activation))
114
+ }
115
+
116
+ // 4. Messages (horizontal arrows with labels)
117
+ for (const message of diagram.messages) {
118
+ parts.push(renderMessage(message, fontSizes))
119
+ }
120
+
121
+ // 5. Notes
122
+ for (const note of diagram.notes) {
123
+ parts.push(renderNote(note, fontSizes))
124
+ }
125
+
126
+ // 6. Actor boxes at top (rendered last so they're on top)
127
+ for (const actor of diagram.actors) {
128
+ parts.push(renderActor(actor, fontSizes))
129
+ }
130
+
131
+ parts.push('</svg>')
132
+ return parts.join('\n')
133
+ }
134
+
135
+ // ============================================================================
136
+ // Arrow marker definitions
137
+ // ============================================================================
138
+
139
+ function arrowMarkerDefs(): string {
140
+ const w = ARROW_HEAD.width
141
+ const h = ARROW_HEAD.height
142
+ return (
143
+ ` <marker id="seq-arrow" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
144
+ `\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" fill="var(--_arrow)" />` +
145
+ `\n </marker>` +
146
+ // Open arrow head (just lines, no fill)
147
+ `\n <marker id="seq-arrow-open" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
148
+ `\n <polyline points="0 0, ${w} ${h / 2}, 0 ${h}" fill="none" stroke="var(--_arrow)" stroke-width="1" />` +
149
+ `\n </marker>`
150
+ )
151
+ }
152
+
153
+ // ============================================================================
154
+ // Component renderers
155
+ // ============================================================================
156
+
157
+ /**
158
+ * Render an actor box (participant = rectangle, actor = stick figure).
159
+ * Wrapped in <g class="actor"> with semantic data attributes.
160
+ */
161
+ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
162
+ const { id, x, y, width, height, label, type } = actor
163
+ const parts: string[] = []
164
+
165
+ // Semantic wrapper with actor metadata
166
+ parts.push(
167
+ `<g class="actor" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}" data-type="${type}">`,
168
+ )
169
+
170
+ if (type === 'actor') {
171
+ // Circle-person icon: outer circle + head circle + shoulders arc.
172
+ // Defined in a 24×24 coordinate space, scaled to 90% of the actor box height
173
+ // and centered both horizontally and vertically within the box.
174
+ // Stroke width is inverse-scaled so the visual thickness matches STROKE_WIDTHS.outerBox.
175
+ const s = (height / 24) * 0.9
176
+ const tx = x - 12 * s // center icon horizontally on actor.x
177
+ const ty = y + (height - 24 * s) / 2 // center icon vertically in actor box
178
+ const sw = STROKE_WIDTHS.outerBox / s // compensate for scale transform
179
+ const iconStroke = 'var(--_line)' // use line color for actor icon strokes
180
+
181
+ parts.push(
182
+ ` <g transform="translate(${tx},${ty}) scale(${s})">` +
183
+ // Outer circle
184
+ `\n <path d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
185
+ // Head
186
+ `\n <path d="M15 10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10C9 8.34315 10.3431 7 12 7C13.6569 7 15 8.34315 15 10Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
187
+ // Shoulders
188
+ `\n <path d="M5.62842 18.3563C7.08963 17.0398 9.39997 16 12 16C14.6 16 16.9104 17.0398 18.3716 18.3563" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
189
+ `\n </g>`,
190
+ )
191
+ // Label below the icon (supports multi-line)
192
+ parts.push(
193
+ ' ' +
194
+ renderMultilineText(
195
+ label,
196
+ x,
197
+ y + height + 14,
198
+ fontSizes.nodeLabel,
199
+ `font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
200
+ ),
201
+ )
202
+ } else {
203
+ // Participant: rectangle box with label (supports multi-line)
204
+ const boxX = x - width / 2
205
+ parts.push(
206
+ ` <rect x="${boxX}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" ` +
207
+ `fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
208
+ )
209
+ parts.push(
210
+ ' ' +
211
+ renderMultilineText(
212
+ label,
213
+ x,
214
+ y + height / 2,
215
+ fontSizes.nodeLabel,
216
+ `font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
217
+ ),
218
+ )
219
+ }
220
+
221
+ parts.push('</g>')
222
+ return parts.join('\n')
223
+ }
224
+
225
+ /**
226
+ * Render a lifeline (dashed vertical line from actor to bottom).
227
+ * Includes data-actor to link to its actor.
228
+ */
229
+ function renderLifeline(lifeline: Lifeline): string {
230
+ const line =
231
+ `<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
232
+ `x1="${lifeline.x}" y1="${lifeline.topY}" x2="${lifeline.x}" y2="${lifeline.bottomY}" ` +
233
+ `stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`
234
+ if (!lifeline.destroyed) return line
235
+ // `destroy X`: the lifeline ends at the destroying message's row, marked
236
+ // with a cross centred on it — the same glyph Mermaid uses. Drawn in the
237
+ // lifeline pass (before messages), so the destroying arrow lands on top.
238
+ const { x, bottomY: y } = lifeline
239
+ const r = DESTROY_CROSS_HALF
240
+ return (
241
+ line +
242
+ `\n<path class="destroy" data-actor="${escapeAttr(lifeline.actorId)}" ` +
243
+ `d="M${x - r} ${y - r} L${x + r} ${y + r} M${x + r} ${y - r} L${x - r} ${y + r}" ` +
244
+ `fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.outerBox}" stroke-linecap="round" />`
245
+ )
246
+ }
247
+
248
+ /** Half-size of the `destroy` cross at the end of a lifeline, in px. */
249
+ const DESTROY_CROSS_HALF = 8
250
+
251
+ /**
252
+ * Render an activation box (narrow filled rectangle on lifeline).
253
+ * Includes data-actor to link to its actor.
254
+ */
255
+ function renderActivation(activation: Activation): string {
256
+ return (
257
+ `<rect class="activation" data-actor="${escapeAttr(activation.actorId)}" ` +
258
+ `x="${activation.x}" y="${activation.topY}" width="${activation.width}" height="${activation.bottomY - activation.topY}" ` +
259
+ `fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`
260
+ )
261
+ }
262
+
263
+ /**
264
+ * Render a message arrow with label.
265
+ * Wrapped in <g class="message"> with semantic data attributes.
266
+ */
267
+ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
268
+ const parts: string[] = []
269
+ const dashArray = msg.lineStyle === 'dashed' ? ' stroke-dasharray="6 4"' : ''
270
+ const markerId = msg.arrowHead === 'filled' ? 'seq-arrow' : 'seq-arrow-open'
271
+ // Bidirectional arrows (`<<->>` / `<<-->>`) reuse the same marker on both
272
+ // ends — the marker defs use orient="auto-start-reverse", which SVG
273
+ // automatically flips 180° for marker-start so it points outward at the
274
+ // line's start instead of reusing the marker-end orientation.
275
+ const markerStart = msg.bidirectional
276
+ ? ` marker-start="url(#${markerId})"`
277
+ : ''
278
+
279
+ // Semantic wrapper with message metadata
280
+ parts.push(
281
+ `<g class="message" data-from="${escapeAttr(msg.from)}" data-to="${escapeAttr(msg.to)}" ` +
282
+ `data-label="${escapeAttr(msg.label)}" data-line-style="${msg.lineStyle}" ` +
283
+ `data-arrow-head="${msg.arrowHead}" data-self="${msg.isSelf}" data-bidirectional="${msg.bidirectional}">`,
284
+ )
285
+
286
+ if (msg.isSelf) {
287
+ // Self-message: curved loop going right and back
288
+ // Loop dimensions - loopH is fixed, loopW provides minimum clearance
289
+ const loopW = 30
290
+ const loopH = 20
291
+ const labelPadding = 8 // Space between loop and label
292
+ parts.push(
293
+ ` <polyline points="${msg.x1},${msg.y} ${msg.x1 + loopW},${msg.y} ${msg.x1 + loopW},${msg.y + loopH} ${msg.x2},${msg.y + loopH}" ` +
294
+ `fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
295
+ )
296
+ // Label to the right of the loop (supports multi-line)
297
+ parts.push(
298
+ ' ' +
299
+ renderMultilineText(
300
+ msg.label,
301
+ msg.x1 + loopW + labelPadding,
302
+ msg.y + loopH / 2,
303
+ fontSizes.edgeLabel,
304
+ `font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
305
+ ),
306
+ )
307
+ } else {
308
+ // Normal message: horizontal arrow
309
+ parts.push(
310
+ ` <line x1="${msg.x1}" y1="${msg.y}" x2="${msg.x2}" y2="${msg.y}" ` +
311
+ `stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
312
+ )
313
+ // Label above the arrow, centered (supports multi-line)
314
+ const midX = (msg.x1 + msg.x2) / 2
315
+ parts.push(
316
+ ' ' +
317
+ renderMultilineText(
318
+ msg.label,
319
+ midX,
320
+ msg.y - 10,
321
+ fontSizes.edgeLabel,
322
+ `font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
323
+ ),
324
+ )
325
+ }
326
+
327
+ if (msg.seqNumber !== undefined) {
328
+ parts.push(' ' + renderSeqNumberBadge(msg, fontSizes))
329
+ }
330
+
331
+ parts.push('</g>')
332
+ return parts.join('\n')
333
+ }
334
+
335
+ /**
336
+ * Render the small `autonumber` badge Mermaid draws near the start of a
337
+ * message arrow: a circle with the sequence number centered inside it,
338
+ * layered on top of the arrow rather than folded into the label text.
339
+ */
340
+ function renderSeqNumberBadge(
341
+ msg: PositionedMessage,
342
+ fontSizes: FontSizes,
343
+ ): string {
344
+ const radius = 8
345
+ const fontSize = Math.max(fontSizes.edgeLabel - 2, 8)
346
+ // Bidirectional messages also draw an arrowhead at x1 (the departure
347
+ // end) — badge-at-x1 would sit on top of it. Shift the badge into the
348
+ // arrow span, clear of the marker, in that case. One-way messages have
349
+ // no marker at x1, so they keep the badge exactly at the departure
350
+ // point as before.
351
+ const cx = msg.bidirectional
352
+ ? msg.x1 + Math.sign(msg.x2 - msg.x1) * (radius + ARROW_HEAD.width)
353
+ : msg.x1
354
+ return (
355
+ `<g class="seq-number">` +
356
+ `<circle cx="${cx}" cy="${msg.y}" r="${radius}" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
357
+ renderMultilineText(
358
+ String(msg.seqNumber),
359
+ cx,
360
+ msg.y,
361
+ fontSize,
362
+ `font-size="${fontSize}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_arrow)"`,
363
+ ) +
364
+ `</g>`
365
+ )
366
+ }
367
+
368
+ /**
369
+ * How much of a `box` colour shows through: the colour is mixed into the
370
+ * theme background at this percentage rather than painted as-is, so
371
+ * `box Purple` reads as a purple tint on a light theme and a deep purple on
372
+ * a dark one instead of the same opaque swatch on both (Mermaid paints the
373
+ * literal colour). The stroke and label use theme variables, so a colourless
374
+ * box still shows its grouping in every theme.
375
+ */
376
+ const BOX_COLOR_MIX_PERCENT = 35
377
+
378
+ /**
379
+ * Render a `box … end` participant-group background: a full-height rect
380
+ * behind the grouped lifelines with the label centred in its top band.
381
+ * Wrapped in <g class="participant-box"> with semantic data attributes.
382
+ *
383
+ * `box.color` is emitted verbatim inside `color-mix()` — safe because the
384
+ * parser only records a value that matched its fixed named-colour list or
385
+ * the hex / rgb() / hsl() patterns (see box-color.ts), never arbitrary
386
+ * diagram text.
387
+ */
388
+ function renderParticipantBox(
389
+ box: PositionedParticipantBox,
390
+ fontSizes: FontSizes,
391
+ ): string {
392
+ const labelAttr = box.label ? ` data-label="${escapeAttr(box.label)}"` : ''
393
+ const colorAttr =
394
+ box.color !== undefined ? ` data-color="${escapeAttr(box.color)}"` : ''
395
+ const fill =
396
+ box.color !== undefined
397
+ ? `color-mix(in srgb, ${escapeAttr(box.color)} ${BOX_COLOR_MIX_PERCENT}%, var(--bg))`
398
+ : 'none'
399
+ const parts: string[] = [
400
+ `<g class="participant-box"${labelAttr}${colorAttr}>`,
401
+ ` <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="4" ry="4" ` +
402
+ `fill="${fill}" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
403
+ ]
404
+ if (box.label) {
405
+ // Centred in the label band the layout reserved above the actor boxes.
406
+ parts.push(
407
+ ' ' +
408
+ renderMultilineText(
409
+ box.label,
410
+ box.x + box.width / 2,
411
+ box.y + boxLabelHeight(fontSizes.edgeLabel) / 2,
412
+ fontSizes.edgeLabel,
413
+ `font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
414
+ ),
415
+ )
416
+ }
417
+ parts.push('</g>')
418
+ return parts.join('\n')
419
+ }
420
+
421
+ /**
422
+ * Render a block background (loop/alt/opt).
423
+ * Wrapped in <g class="block"> with semantic data attributes.
424
+ */
425
+ function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
426
+ const parts: string[] = []
427
+
428
+ // Semantic wrapper with block metadata
429
+ const labelAttr = block.label
430
+ ? ` data-label="${escapeAttr(block.label)}"`
431
+ : ''
432
+ parts.push(
433
+ `<g class="block" data-type="${escapeAttr(block.type)}"${labelAttr}>`,
434
+ )
435
+
436
+ // Outer rectangle
437
+ parts.push(
438
+ ` <rect x="${block.x}" y="${block.y}" width="${block.width}" height="${block.height}" ` +
439
+ `rx="0" ry="0" fill="none" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
440
+ )
441
+
442
+ // Type label tab (top-left corner)
443
+ // For multi-line block labels, we use the first line for the tab but show full label
444
+ const labelText = `${block.type}${block.label ? ` [${block.label}]` : ''}`
445
+ // Audited for issue #100: `String.prototype.split` always returns an
446
+ // array with at least one element (even splitting `''` yields `['']`),
447
+ // so index 0 is guaranteed to exist regardless of whether `labelText`
448
+ // contains a newline. `noUncheckedIndexedAccess` can't see that
449
+ // language-level guarantee; left as-is, no behavior change.
450
+ const firstLine = labelText.split('\n')[0]!
451
+ const tabWidth =
452
+ estimateTextWidth(
453
+ firstLine,
454
+ fontSizes.edgeLabel,
455
+ FONT_WEIGHTS.groupHeader,
456
+ ) + 16
457
+ const tabHeight = 18
458
+
459
+ parts.push(
460
+ ` <rect x="${block.x}" y="${block.y}" width="${tabWidth}" height="${tabHeight}" ` +
461
+ `fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
462
+ )
463
+ // Block type label (supports multi-line via <br> tags)
464
+ parts.push(
465
+ ' ' +
466
+ renderMultilineText(
467
+ labelText,
468
+ block.x + 6,
469
+ block.y + tabHeight / 2,
470
+ fontSizes.edgeLabel,
471
+ `font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
472
+ ),
473
+ )
474
+
475
+ // Divider lines (for alt/else, par/and)
476
+ for (const divider of block.dividers) {
477
+ parts.push(
478
+ ` <line x1="${block.x}" y1="${divider.y}" x2="${block.x + block.width}" y2="${divider.y}" ` +
479
+ `stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`,
480
+ )
481
+ if (divider.label) {
482
+ // Divider label supports multi-line
483
+ parts.push(
484
+ ' ' +
485
+ renderMultilineText(
486
+ `[${divider.label}]`,
487
+ block.x + 8,
488
+ divider.y + 14,
489
+ fontSizes.edgeLabel,
490
+ `font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
491
+ ),
492
+ )
493
+ }
494
+ }
495
+
496
+ parts.push('</g>')
497
+ return parts.join('\n')
498
+ }
499
+
500
+ /**
501
+ * Render a note box.
502
+ * Wrapped in <g class="note"> with semantic data attributes.
503
+ */
504
+ function renderNote(note: PositionedNote, fontSizes: FontSizes): string {
505
+ // Dog-ear note: polygon with clipped top-right corner + fold triangle
506
+ const foldSize = 6
507
+ const { x, y, width: w, height: h } = note
508
+
509
+ // Build actor reference attribute if present
510
+ const actorsAttr =
511
+ note.actors && note.actors.length > 0
512
+ ? ` data-actors="${note.actors.map(escapeAttr).join(',')}"`
513
+ : ''
514
+ const positionAttr = note.position
515
+ ? ` data-position="${escapeAttr(note.position)}"`
516
+ : ''
517
+
518
+ // Note body: polygon with top-right corner cut off
519
+ // (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)
520
+ const bodyPoints = [
521
+ `${x},${y}`,
522
+ `${x + w - foldSize},${y}`,
523
+ `${x + w},${y + foldSize}`,
524
+ `${x + w},${y + h}`,
525
+ `${x},${y + h}`,
526
+ ].join(' ')
527
+
528
+ return (
529
+ `<g class="note"${positionAttr}${actorsAttr}>` +
530
+ // Note body with bg fill and clipped corner
531
+ `\n <polygon points="${bodyPoints}" ` +
532
+ `fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
533
+ // Fold triangle (the folded-over corner)
534
+ `\n <polygon points="${x + w - foldSize},${y} ${x + w},${y + foldSize} ${x + w - foldSize},${y + foldSize}" ` +
535
+ `fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
536
+ // Note text (supports multi-line)
537
+ `\n ${renderMultilineText(
538
+ note.text,
539
+ x + w / 2,
540
+ y + h / 2,
541
+ fontSizes.edgeLabel,
542
+ `font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
543
+ )}` +
544
+ `\n</g>`
545
+ )
546
+ }