@diagrammo/dgmo 0.8.19 → 0.8.21

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 (74) hide show
  1. package/dist/cli.cjs +92 -131
  2. package/dist/editor.cjs +13 -1
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +13 -1
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +13 -1
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +13 -1
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +4524 -1511
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +427 -186
  13. package/dist/index.d.ts +427 -186
  14. package/dist/index.js +4526 -1503
  15. package/dist/index.js.map +1 -1
  16. package/docs/guide/chart-mindmap.md +198 -0
  17. package/docs/guide/chart-sequence.md +23 -1
  18. package/docs/guide/chart-wireframe.md +100 -0
  19. package/docs/guide/index.md +8 -0
  20. package/docs/language-reference.md +210 -2
  21. package/package.json +22 -9
  22. package/src/boxes-and-lines/collapse.ts +21 -3
  23. package/src/boxes-and-lines/layout.ts +51 -9
  24. package/src/boxes-and-lines/parser.ts +16 -4
  25. package/src/boxes-and-lines/renderer.ts +121 -23
  26. package/src/boxes-and-lines/types.ts +1 -0
  27. package/src/c4/parser.ts +8 -7
  28. package/src/class/parser.ts +6 -0
  29. package/src/cli.ts +1 -9
  30. package/src/completion.ts +26 -0
  31. package/src/d3.ts +169 -266
  32. package/src/dgmo-router.ts +103 -5
  33. package/src/diagnostics.ts +16 -6
  34. package/src/echarts.ts +43 -10
  35. package/src/editor/keywords.ts +12 -0
  36. package/src/er/parser.ts +22 -2
  37. package/src/gantt/renderer.ts +2 -2
  38. package/src/graph/flowchart-parser.ts +89 -52
  39. package/src/graph/layout.ts +73 -9
  40. package/src/graph/state-collapse.ts +78 -0
  41. package/src/graph/state-parser.ts +60 -35
  42. package/src/graph/state-renderer.ts +139 -34
  43. package/src/index.ts +41 -16
  44. package/src/infra/parser.ts +9 -2
  45. package/src/kanban/renderer.ts +305 -59
  46. package/src/mindmap/collapse.ts +88 -0
  47. package/src/mindmap/layout.ts +605 -0
  48. package/src/mindmap/parser.ts +379 -0
  49. package/src/mindmap/renderer.ts +543 -0
  50. package/src/mindmap/text-wrap.ts +207 -0
  51. package/src/mindmap/types.ts +55 -0
  52. package/src/palettes/color-utils.ts +4 -12
  53. package/src/palettes/index.ts +0 -4
  54. package/src/render.ts +31 -20
  55. package/src/sequence/parser.ts +7 -2
  56. package/src/sequence/renderer.ts +141 -21
  57. package/src/sharing.ts +2 -0
  58. package/src/sitemap/layout.ts +35 -12
  59. package/src/sitemap/renderer.ts +1 -6
  60. package/src/utils/arrows.ts +180 -11
  61. package/src/utils/d3-types.ts +4 -0
  62. package/src/utils/export-container.ts +3 -2
  63. package/src/utils/legend-constants.ts +0 -4
  64. package/src/utils/legend-d3.ts +1 -0
  65. package/src/utils/legend-layout.ts +2 -2
  66. package/src/utils/parsing.ts +2 -0
  67. package/src/utils/time-ticks.ts +213 -0
  68. package/src/wireframe/layout.ts +460 -0
  69. package/src/wireframe/parser.ts +956 -0
  70. package/src/wireframe/renderer.ts +1293 -0
  71. package/src/wireframe/types.ts +110 -0
  72. package/src/branding.ts +0 -67
  73. package/src/dgmo-mermaid.ts +0 -262
  74. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -0,0 +1,1293 @@
1
+ // ============================================================
2
+ // Wireframe Diagram Renderer (D3-based SVG)
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import { FONT_FAMILY } from '../fonts';
7
+ import type { PaletteColors } from '../palettes';
8
+ import { mix } from '../palettes/color-utils';
9
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
10
+ import type { WireframeElement, ParsedWireframe } from './types';
11
+ import type { WireframeLayout, WireframeLayoutNode } from './layout';
12
+
13
+ // ============================================================
14
+ // Constants
15
+ // ============================================================
16
+
17
+ const INPUT_CORNER = 4;
18
+ const BUTTON_CORNER = 6;
19
+ const GROUP_CORNER = 8;
20
+
21
+ /** Semantic color mapping — wireframe-only, not a palette schema change (ADR-2) */
22
+ const SEMANTIC_COLORS: Record<string, (p: PaletteColors) => string> = {
23
+ destructive: (p) => p.destructive,
24
+ success: (p) => p.colors.green,
25
+ warning: (p) => p.colors.yellow,
26
+ info: (p) => p.colors.blue,
27
+ };
28
+
29
+ function getSemanticColor(
30
+ state: string,
31
+ palette: PaletteColors
32
+ ): string | null {
33
+ const fn = SEMANTIC_COLORS[state];
34
+ return fn ? fn(palette) : null;
35
+ }
36
+
37
+ function getElementSemanticColor(
38
+ el: WireframeElement,
39
+ palette: PaletteColors
40
+ ): string | null {
41
+ for (const s of el.states) {
42
+ const c = getSemanticColor(s, palette);
43
+ if (c) return c;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // ============================================================
49
+ // Types
50
+ // ============================================================
51
+
52
+ type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
53
+
54
+ interface RenderContext {
55
+ palette: PaletteColors;
56
+ isTransparent: boolean;
57
+ isDark: boolean;
58
+ showGroupLabels: boolean;
59
+ }
60
+
61
+ // ============================================================
62
+ // Main Renderer
63
+ // ============================================================
64
+
65
+ interface WireframeRenderOptions {
66
+ exportDims?: { width?: number; height?: number };
67
+ theme?: string;
68
+ onClickItem?: (lineNumber: number) => void;
69
+ /** Controls group state */
70
+ controlsExpanded?: boolean;
71
+ fitWidth?: boolean;
72
+ showGroupLabels?: boolean;
73
+ onControlsExpand?: () => void;
74
+ onControlsToggle?: (id: string, active: boolean) => void;
75
+ }
76
+
77
+ export function renderWireframe(
78
+ container: HTMLDivElement,
79
+ parsed: ParsedWireframe,
80
+ layout: WireframeLayout,
81
+ palette: PaletteColors,
82
+ isDark: boolean,
83
+ _onClickItem?: (lineNumber: number) => void,
84
+ exportDims?: { width?: number; height?: number },
85
+ theme?: string,
86
+ options?: WireframeRenderOptions
87
+ ): void {
88
+ // Merge legacy positional args with options
89
+ const opts = options ?? {};
90
+ const effectiveExportDims = opts.exportDims ?? exportDims;
91
+ const effectiveTheme = opts.theme ?? theme;
92
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
93
+
94
+ const isExport = !!effectiveExportDims;
95
+ const width = effectiveExportDims?.width ?? container.clientWidth;
96
+ const height = effectiveExportDims?.height ?? container.clientHeight;
97
+
98
+ const svg = d3Selection
99
+ .select(container)
100
+ .append('svg')
101
+ .attr('viewBox', `0 0 ${layout.width} ${layout.height}`)
102
+ .attr('xmlns', 'http://www.w3.org/2000/svg')
103
+ .style('font-family', FONT_FAMILY);
104
+
105
+ if (isExport) {
106
+ svg.attr('width', width).attr('height', height);
107
+ } else {
108
+ // Fill width, scale height proportionally — container scrolls vertically
109
+ svg
110
+ .attr('width', '100%')
111
+ .attr('height', 'auto')
112
+ .attr('preserveAspectRatio', 'xMidYMin meet')
113
+ .style('display', 'block');
114
+ }
115
+
116
+ const ctx: RenderContext = {
117
+ palette,
118
+ isTransparent: effectiveTheme === 'transparent',
119
+ isDark,
120
+ showGroupLabels: opts.showGroupLabels ?? true,
121
+ };
122
+
123
+ // Background
124
+ if (!ctx.isTransparent) {
125
+ svg
126
+ .append('rect')
127
+ .attr('width', layout.width)
128
+ .attr('height', layout.height)
129
+ .attr('fill', palette.bg)
130
+ .attr('rx', 8);
131
+ }
132
+
133
+ const mainG = svg.append('g').attr('transform', `translate(20, 20)`);
134
+
135
+ // Title — only rendered in SVG for export; live preview renders in HTML
136
+ let titleOffset = 0;
137
+ if (isExport && parsed.title) {
138
+ mainG
139
+ .append('text')
140
+ .attr('x', 0)
141
+ .attr('y', TITLE_FONT_SIZE)
142
+ .attr('fill', palette.text)
143
+ .attr('font-size', TITLE_FONT_SIZE)
144
+ .attr('font-weight', TITLE_FONT_WEIGHT)
145
+ .text(parsed.title);
146
+ titleOffset = layout.titleHeight;
147
+ }
148
+
149
+ const contentG = mainG
150
+ .append('g')
151
+ .attr('transform', `translate(0, ${titleOffset})`);
152
+
153
+ // Render main nodes
154
+ for (const node of layout.nodes) {
155
+ renderNode(contentG, node, ctx, 0);
156
+ }
157
+
158
+ // Render modals
159
+ if (layout.modalNodes.length > 0) {
160
+ const modalG = contentG.append('g').attr('class', 'wireframe-modals');
161
+ for (const modalNode of layout.modalNodes) {
162
+ renderModal(modalG, modalNode, ctx);
163
+ }
164
+ }
165
+ }
166
+
167
+ // ============================================================
168
+ // Node Rendering
169
+ // ============================================================
170
+
171
+ function renderNode(
172
+ parent: GSelection,
173
+ node: WireframeLayoutNode,
174
+ ctx: RenderContext,
175
+ depth: number
176
+ ): void {
177
+ const el = node.element;
178
+ const g = parent
179
+ .append('g')
180
+ .attr('transform', `translate(${node.x}, ${node.y})`)
181
+ .attr('data-line-number', el.lineNumber);
182
+
183
+ // Apply disabled state
184
+ if (el.states.includes('disabled')) {
185
+ g.attr('opacity', 0.5);
186
+ }
187
+
188
+ // Skeleton override: render as grey placeholder
189
+ if (el.isSkeleton && el.type !== 'skeleton') {
190
+ renderSkeletonPlaceholder(g, node, ctx);
191
+ return;
192
+ }
193
+
194
+ switch (el.type) {
195
+ case 'group':
196
+ renderGroup(g, node, ctx, depth);
197
+ break;
198
+ case 'textInput':
199
+ renderTextInput(g, node, ctx);
200
+ break;
201
+ case 'button':
202
+ renderButton(g, node, ctx);
203
+ break;
204
+ case 'dropdown':
205
+ renderDropdown(g, node, ctx);
206
+ break;
207
+ case 'checkbox':
208
+ renderCheckbox(g, node, ctx);
209
+ break;
210
+ case 'radio':
211
+ renderRadio(g, node, ctx);
212
+ break;
213
+ case 'heading':
214
+ renderHeading(g, node, ctx);
215
+ break;
216
+ case 'divider':
217
+ renderDivider(g, node, ctx);
218
+ break;
219
+ case 'text':
220
+ renderText(g, node, ctx);
221
+ break;
222
+ case 'listItem':
223
+ renderListItem(g, node, ctx);
224
+ break;
225
+ case 'nav':
226
+ renderNav(g, node, ctx);
227
+ break;
228
+ case 'tabs':
229
+ renderTabs(g, node, ctx);
230
+ break;
231
+ case 'table':
232
+ renderTable(g, node, ctx);
233
+ break;
234
+ case 'image':
235
+ renderImage(g, node, ctx);
236
+ break;
237
+ case 'skeleton':
238
+ renderSkeletonBlock(g, node, ctx);
239
+ break;
240
+ case 'alert':
241
+ renderAlert(g, node, ctx);
242
+ break;
243
+ case 'progress':
244
+ renderProgress(g, node, ctx);
245
+ break;
246
+ case 'chart':
247
+ renderChart(g, node, ctx);
248
+ break;
249
+ default:
250
+ renderText(g, node, ctx);
251
+ }
252
+ }
253
+
254
+ // ============================================================
255
+ // Element Renderers — Visual Archetypes
256
+ // ============================================================
257
+
258
+ function renderGroup(
259
+ g: GSelection,
260
+ node: WireframeLayoutNode,
261
+ ctx: RenderContext,
262
+ depth: number
263
+ ): void {
264
+ const { palette, isTransparent } = ctx;
265
+ const el = node.element;
266
+
267
+ // Inline rows and label-field wrappers — no group chrome, just render children
268
+ if (el.metadata._inlineRow === 'true' || el.metadata._labelField === 'true') {
269
+ for (const child of node.children) {
270
+ renderNode(g, child, ctx, depth);
271
+ }
272
+ return;
273
+ }
274
+
275
+ // Depth-based fill shading: mix textMuted into bg for visible section tints.
276
+ // mix(a, b, pct) — pct is 0-100 where 100 = 100% of a.
277
+ // depth 0 → 12% textMuted, depth 1 → 7%, depth 2 → 4%, depth 3+ → 2%
278
+ const tintSteps = [12, 7, 4, 2];
279
+ const tintPct = tintSteps[Math.min(depth, tintSteps.length - 1)];
280
+ const fillColor = mix(palette.textMuted, palette.bg, tintPct);
281
+
282
+ // Container background — solid border + depth-based shading
283
+ if (isTransparent) {
284
+ g.append('rect')
285
+ .attr('width', node.width)
286
+ .attr('height', node.height)
287
+ .attr('fill', 'none')
288
+ .attr('stroke', palette.border)
289
+ .attr('stroke-width', 1)
290
+ .attr('rx', GROUP_CORNER);
291
+ } else {
292
+ g.append('rect')
293
+ .attr('width', node.width)
294
+ .attr('height', node.height)
295
+ .attr('fill', fillColor)
296
+ .attr('stroke', palette.border)
297
+ .attr('stroke-width', 1)
298
+ .attr('rx', GROUP_CORNER);
299
+ }
300
+
301
+ // Label
302
+ if (el.label && ctx.showGroupLabels) {
303
+ g.append('text')
304
+ .attr('x', 10)
305
+ .attr('y', 18)
306
+ .attr('fill', palette.textMuted)
307
+ .attr('font-size', 11)
308
+ .attr('font-weight', '600')
309
+ .attr('letter-spacing', '0.5px')
310
+ .text(el.label.toUpperCase());
311
+ }
312
+
313
+ // Collapsed indicator
314
+ if (el.states.includes('collapsed')) {
315
+ g.append('text')
316
+ .attr('x', node.width - 20)
317
+ .attr('y', 18)
318
+ .attr('fill', palette.textMuted)
319
+ .attr('font-size', 12)
320
+ .text('▶');
321
+ return;
322
+ }
323
+
324
+ // Scrollable indicator
325
+ if (el.states.includes('scrollable')) {
326
+ g.append('rect')
327
+ .attr('x', node.width - 6)
328
+ .attr('y', 28)
329
+ .attr('width', 3)
330
+ .attr('height', Math.min(node.height - 36, 40))
331
+ .attr('fill', palette.border)
332
+ .attr('rx', 1.5);
333
+ }
334
+
335
+ // Render children at increased depth
336
+ for (const child of node.children) {
337
+ renderNode(g, child, ctx, depth + 1);
338
+ }
339
+ }
340
+
341
+ function renderTextInput(
342
+ g: GSelection,
343
+ node: WireframeLayoutNode,
344
+ ctx: RenderContext
345
+ ): void {
346
+ const { palette, isTransparent } = ctx;
347
+ const el = node.element;
348
+ const h = node.height;
349
+
350
+ // Input box
351
+ g.append('rect')
352
+ .attr('width', node.width)
353
+ .attr('height', h)
354
+ .attr('fill', isTransparent ? 'none' : palette.bg)
355
+ .attr('stroke', palette.border)
356
+ .attr('rx', INPUT_CORNER);
357
+
358
+ if (el.fieldVariant === 'password') {
359
+ // Dots for password
360
+ g.append('text')
361
+ .attr('x', 10)
362
+ .attr('y', h / 2 + 4)
363
+ .attr('fill', palette.text)
364
+ .attr('font-size', 16)
365
+ .text('• • • • • •');
366
+ } else {
367
+ // Placeholder text
368
+ g.append('text')
369
+ .attr('x', 10)
370
+ .attr('y', h / 2 + 4)
371
+ .attr('fill', palette.textMuted)
372
+ .attr('font-size', 13)
373
+ .text(el.label || 'Text input');
374
+ }
375
+
376
+ // Cursor line
377
+ if (el.fieldVariant !== 'password') {
378
+ const textWidth = Math.min(
379
+ (el.label || 'Text input').length * 7 + 14,
380
+ node.width - 20
381
+ );
382
+ g.append('line')
383
+ .attr('x1', textWidth)
384
+ .attr('y1', 8)
385
+ .attr('x2', textWidth)
386
+ .attr('y2', h - 8)
387
+ .attr('stroke', palette.primary)
388
+ .attr('stroke-width', 1.5)
389
+ .attr('opacity', 0.6);
390
+ }
391
+
392
+ // Textarea multi-line indicator
393
+ if (el.fieldVariant === 'textarea') {
394
+ // Grip handle
395
+ for (let i = 0; i < 3; i++) {
396
+ g.append('line')
397
+ .attr('x1', node.width - 12 + i * 3)
398
+ .attr('y1', h - 4)
399
+ .attr('x2', node.width - 4)
400
+ .attr('y2', h - 12 + i * 3)
401
+ .attr('stroke', palette.border)
402
+ .attr('stroke-width', 1);
403
+ }
404
+ }
405
+ }
406
+
407
+ function renderButton(
408
+ g: GSelection,
409
+ node: WireframeLayoutNode,
410
+ ctx: RenderContext
411
+ ): void {
412
+ const { palette, isTransparent } = ctx;
413
+ const el = node.element;
414
+ const isGhost = el.states.includes('ghost');
415
+ const semanticColor = getElementSemanticColor(el, palette);
416
+ const isPrimary = el.states.includes('primary');
417
+
418
+ let fill = isPrimary ? palette.primary : palette.secondary;
419
+ let textColor = palette.bg;
420
+
421
+ if (semanticColor) {
422
+ fill = semanticColor;
423
+ }
424
+
425
+ if (isGhost || isTransparent) {
426
+ // Outline button
427
+ g.append('rect')
428
+ .attr('width', node.width)
429
+ .attr('height', node.height)
430
+ .attr('fill', 'none')
431
+ .attr('stroke', fill)
432
+ .attr('stroke-width', 1.5)
433
+ .attr('rx', BUTTON_CORNER);
434
+ textColor = fill;
435
+ } else {
436
+ g.append('rect')
437
+ .attr('width', node.width)
438
+ .attr('height', node.height)
439
+ .attr('fill', fill)
440
+ .attr('rx', BUTTON_CORNER);
441
+ }
442
+
443
+ g.append('text')
444
+ .attr('x', node.width / 2)
445
+ .attr('y', node.height / 2 + 4)
446
+ .attr('fill', textColor)
447
+ .attr('font-size', 13)
448
+ .attr('font-weight', '600')
449
+ .attr('text-anchor', 'middle')
450
+ .text(el.label);
451
+ }
452
+
453
+ function renderDropdown(
454
+ g: GSelection,
455
+ node: WireframeLayoutNode,
456
+ ctx: RenderContext
457
+ ): void {
458
+ const { palette, isTransparent } = ctx;
459
+ const el = node.element;
460
+
461
+ // Select box
462
+ g.append('rect')
463
+ .attr('width', node.width)
464
+ .attr('height', node.height)
465
+ .attr('fill', isTransparent ? 'none' : palette.bg)
466
+ .attr('stroke', palette.border)
467
+ .attr('rx', INPUT_CORNER);
468
+
469
+ // Selected text
470
+ g.append('text')
471
+ .attr('x', 10)
472
+ .attr('y', node.height / 2 + 4)
473
+ .attr('fill', palette.text)
474
+ .attr('font-size', 13)
475
+ .text(el.label || (el.options?.[0] ?? 'Select...'));
476
+
477
+ // Chevron
478
+ const cx = node.width - 16;
479
+ const cy = node.height / 2;
480
+ g.append('path')
481
+ .attr('d', `M${cx - 4},${cy - 2} L${cx},${cy + 2} L${cx + 4},${cy - 2}`)
482
+ .attr('fill', 'none')
483
+ .attr('stroke', palette.textMuted)
484
+ .attr('stroke-width', 1.5);
485
+ }
486
+
487
+ function renderCheckbox(
488
+ g: GSelection,
489
+ node: WireframeLayoutNode,
490
+ ctx: RenderContext
491
+ ): void {
492
+ const { palette } = ctx;
493
+ const el = node.element;
494
+ const isToggle = el.states.includes('toggle');
495
+
496
+ if (isToggle) {
497
+ // Toggle switch
498
+ const trackW = 32;
499
+ const trackH = 18;
500
+ const cy = node.height / 2;
501
+
502
+ g.append('rect')
503
+ .attr('x', 0)
504
+ .attr('y', cy - trackH / 2)
505
+ .attr('width', trackW)
506
+ .attr('height', trackH)
507
+ .attr('rx', trackH / 2)
508
+ .attr('fill', el.checked ? palette.primary : palette.border);
509
+
510
+ g.append('circle')
511
+ .attr('cx', el.checked ? trackW - trackH / 2 : trackH / 2)
512
+ .attr('cy', cy)
513
+ .attr('r', trackH / 2 - 2)
514
+ .attr('fill', palette.bg);
515
+
516
+ if (el.label) {
517
+ g.append('text')
518
+ .attr('x', trackW + 8)
519
+ .attr('y', cy + 4)
520
+ .attr('fill', palette.text)
521
+ .attr('font-size', 13)
522
+ .text(el.label);
523
+ }
524
+ } else {
525
+ // Checkbox square
526
+ const boxSize = 16;
527
+ const cy = node.height / 2;
528
+
529
+ g.append('rect')
530
+ .attr('x', 0)
531
+ .attr('y', cy - boxSize / 2)
532
+ .attr('width', boxSize)
533
+ .attr('height', boxSize)
534
+ .attr('fill', el.checked ? palette.primary : 'none')
535
+ .attr('stroke', el.checked ? palette.primary : palette.border)
536
+ .attr('rx', 3);
537
+
538
+ // Check mark
539
+ if (el.checked) {
540
+ g.append('path')
541
+ .attr('d', `M${3},${cy} L${6},${cy + 3} L${13},${cy - 4}`)
542
+ .attr('fill', 'none')
543
+ .attr('stroke', palette.bg)
544
+ .attr('stroke-width', 2);
545
+ }
546
+
547
+ if (el.label) {
548
+ g.append('text')
549
+ .attr('x', boxSize + 8)
550
+ .attr('y', cy + 4)
551
+ .attr('fill', palette.text)
552
+ .attr('font-size', 13)
553
+ .text(el.label);
554
+ }
555
+ }
556
+ }
557
+
558
+ function renderRadio(
559
+ g: GSelection,
560
+ node: WireframeLayoutNode,
561
+ ctx: RenderContext
562
+ ): void {
563
+ const { palette } = ctx;
564
+ const el = node.element;
565
+ const r = 8;
566
+ const cy = node.height / 2;
567
+
568
+ // Outer circle
569
+ g.append('circle')
570
+ .attr('cx', r)
571
+ .attr('cy', cy)
572
+ .attr('r', r)
573
+ .attr('fill', 'none')
574
+ .attr('stroke', el.selected ? palette.primary : palette.border)
575
+ .attr('stroke-width', 1.5);
576
+
577
+ // Inner dot
578
+ if (el.selected) {
579
+ g.append('circle')
580
+ .attr('cx', r)
581
+ .attr('cy', cy)
582
+ .attr('r', 4)
583
+ .attr('fill', palette.primary);
584
+ }
585
+
586
+ g.append('text')
587
+ .attr('x', r * 2 + 8)
588
+ .attr('y', cy + 4)
589
+ .attr('fill', palette.text)
590
+ .attr('font-size', 13)
591
+ .text(el.label);
592
+ }
593
+
594
+ function renderHeading(
595
+ g: GSelection,
596
+ node: WireframeLayoutNode,
597
+ ctx: RenderContext
598
+ ): void {
599
+ const { palette } = ctx;
600
+ const el = node.element;
601
+ const isH1 = el.headingLevel === 1;
602
+
603
+ g.append('text')
604
+ .attr('x', 0)
605
+ .attr('y', isH1 ? 28 : 22)
606
+ .attr('fill', palette.text)
607
+ .attr('font-size', isH1 ? 22 : 16)
608
+ .attr('font-weight', 'bold')
609
+ .text(el.label);
610
+ }
611
+
612
+ function renderDivider(
613
+ g: GSelection,
614
+ node: WireframeLayoutNode,
615
+ ctx: RenderContext
616
+ ): void {
617
+ const { palette } = ctx;
618
+
619
+ g.append('line')
620
+ .attr('x1', 0)
621
+ .attr('y1', node.height / 2)
622
+ .attr('x2', node.width)
623
+ .attr('y2', node.height / 2)
624
+ .attr('stroke', palette.border)
625
+ .attr('stroke-width', 1);
626
+ }
627
+
628
+ function renderText(
629
+ g: GSelection,
630
+ node: WireframeLayoutNode,
631
+ ctx: RenderContext
632
+ ): void {
633
+ const { palette } = ctx;
634
+ const el = node.element;
635
+
636
+ // Check if this is a label-field wrapper
637
+ if (el.metadata._labelField === 'true' && el.children.length >= 2) {
638
+ for (const child of node.children) {
639
+ renderNode(g, child, ctx, 0);
640
+ }
641
+ return;
642
+ }
643
+
644
+ const color = el.labelFor ? palette.textMuted : palette.text;
645
+
646
+ g.append('text')
647
+ .attr('x', 0)
648
+ .attr('y', 15)
649
+ .attr('fill', color)
650
+ .attr('font-size', 13)
651
+ .text(el.label);
652
+ }
653
+
654
+ function renderListItem(
655
+ g: GSelection,
656
+ node: WireframeLayoutNode,
657
+ ctx: RenderContext
658
+ ): void {
659
+ const { palette } = ctx;
660
+ const el = node.element;
661
+
662
+ // Bullet
663
+ g.append('circle')
664
+ .attr('cx', 4)
665
+ .attr('cy', node.height / 2)
666
+ .attr('r', 2.5)
667
+ .attr('fill', palette.textMuted);
668
+
669
+ g.append('text')
670
+ .attr('x', 14)
671
+ .attr('y', node.height / 2 + 4)
672
+ .attr('fill', palette.text)
673
+ .attr('font-size', 13)
674
+ .text(el.label);
675
+ }
676
+
677
+ function renderNav(
678
+ g: GSelection,
679
+ node: WireframeLayoutNode,
680
+ ctx: RenderContext
681
+ ): void {
682
+ const { palette } = ctx;
683
+ const el = node.element;
684
+
685
+ // Background bar
686
+ g.append('rect')
687
+ .attr('width', node.width)
688
+ .attr('height', node.height)
689
+ .attr('fill', mix(palette.textMuted, palette.bg, 8))
690
+ .attr('rx', 4);
691
+
692
+ // Nav items horizontal
693
+ let x = 12;
694
+ for (const child of el.children) {
695
+ const isActive = child.states.includes('active');
696
+ g.append('text')
697
+ .attr('x', x)
698
+ .attr('y', node.height / 2 + 4)
699
+ .attr('fill', isActive ? palette.primary : palette.text)
700
+ .attr('font-size', 13)
701
+ .attr('font-weight', isActive ? '600' : 'normal')
702
+ .text(child.label);
703
+
704
+ // Active underline
705
+ if (isActive) {
706
+ const textW = child.label.length * 7.5;
707
+ g.append('line')
708
+ .attr('x1', x)
709
+ .attr('y1', node.height - 4)
710
+ .attr('x2', x + textW)
711
+ .attr('y2', node.height - 4)
712
+ .attr('stroke', palette.primary)
713
+ .attr('stroke-width', 2);
714
+ }
715
+
716
+ x += child.label.length * 7.5 + 24;
717
+ }
718
+ }
719
+
720
+ function renderTabs(
721
+ g: GSelection,
722
+ node: WireframeLayoutNode,
723
+ ctx: RenderContext
724
+ ): void {
725
+ const { palette } = ctx;
726
+ const el = node.element;
727
+
728
+ // Bottom border
729
+ g.append('line')
730
+ .attr('x1', 0)
731
+ .attr('y1', node.height - 1)
732
+ .attr('x2', node.width)
733
+ .attr('y2', node.height - 1)
734
+ .attr('stroke', palette.border)
735
+ .attr('stroke-width', 1);
736
+
737
+ // Tab items
738
+ let x = 0;
739
+ for (const child of el.children) {
740
+ const isActive = child.states.includes('active');
741
+ const tabW = child.label.length * 7.5 + 24;
742
+
743
+ if (isActive) {
744
+ // Active tab bottom border
745
+ g.append('line')
746
+ .attr('x1', x)
747
+ .attr('y1', node.height - 1)
748
+ .attr('x2', x + tabW)
749
+ .attr('y2', node.height - 1)
750
+ .attr('stroke', palette.primary)
751
+ .attr('stroke-width', 2);
752
+ }
753
+
754
+ g.append('text')
755
+ .attr('x', x + 12)
756
+ .attr('y', node.height / 2 + 4)
757
+ .attr('fill', isActive ? palette.primary : palette.textMuted)
758
+ .attr('font-size', 13)
759
+ .attr('font-weight', isActive ? '600' : 'normal')
760
+ .text(child.label);
761
+
762
+ x += tabW;
763
+ }
764
+ }
765
+
766
+ function renderTable(
767
+ g: GSelection,
768
+ node: WireframeLayoutNode,
769
+ ctx: RenderContext
770
+ ): void {
771
+ const { palette, isTransparent } = ctx;
772
+ const el = node.element;
773
+ const headers = el.tableHeaders || [];
774
+ const data = el.tableData || [];
775
+ const isSkeleton = el.tableRows !== undefined && el.tableCols !== undefined;
776
+
777
+ const cols = headers.length || el.tableCols || 3;
778
+ const colW = node.width / cols;
779
+ const headerH = 32;
780
+ const rowH = 28;
781
+
782
+ // Table border
783
+ g.append('rect')
784
+ .attr('width', node.width)
785
+ .attr('height', node.height)
786
+ .attr('fill', 'none')
787
+ .attr('stroke', palette.border)
788
+ .attr('rx', 4);
789
+
790
+ // Header row
791
+ g.append('rect')
792
+ .attr('width', node.width)
793
+ .attr('height', headerH)
794
+ .attr(
795
+ 'fill',
796
+ isTransparent ? 'none' : mix(palette.textMuted, palette.bg, 8)
797
+ )
798
+ .attr('rx', 4);
799
+
800
+ for (let c = 0; c < cols; c++) {
801
+ const label = headers[c] || `Col ${c + 1}`;
802
+ g.append('text')
803
+ .attr('x', c * colW + 10)
804
+ .attr('y', headerH / 2 + 4)
805
+ .attr('fill', palette.text)
806
+ .attr('font-size', 12)
807
+ .attr('font-weight', '600')
808
+ .text(label);
809
+ }
810
+
811
+ // Header separator
812
+ g.append('line')
813
+ .attr('x1', 0)
814
+ .attr('y1', headerH)
815
+ .attr('x2', node.width)
816
+ .attr('y2', headerH)
817
+ .attr('stroke', palette.border);
818
+
819
+ // Data rows or skeleton rows
820
+ const rowCount = isSkeleton ? el.tableRows || 3 : data.length;
821
+ for (let r = 0; r < rowCount; r++) {
822
+ const ry = headerH + r * rowH;
823
+
824
+ // Row separator
825
+ if (r > 0) {
826
+ g.append('line')
827
+ .attr('x1', 0)
828
+ .attr('y1', ry)
829
+ .attr('x2', node.width)
830
+ .attr('y2', ry)
831
+ .attr('stroke', palette.border)
832
+ .attr('opacity', 0.3);
833
+ }
834
+
835
+ for (let c = 0; c < cols; c++) {
836
+ if (isSkeleton) {
837
+ // Skeleton placeholder
838
+ g.append('rect')
839
+ .attr('x', c * colW + 10)
840
+ .attr('y', ry + 8)
841
+ .attr('width', colW * 0.6)
842
+ .attr('height', 12)
843
+ .attr('fill', palette.textMuted)
844
+ .attr('opacity', 0.25)
845
+ .attr('rx', 2);
846
+ } else if (data[r]) {
847
+ const cellText = data[r][c] || '';
848
+ renderTableCell(g, cellText, c * colW + 10, ry, colW - 20, rowH, ctx);
849
+ }
850
+ }
851
+ }
852
+ }
853
+
854
+ /** Render a table cell — detects button/checkbox patterns in cell text */
855
+ function renderTableCell(
856
+ g: GSelection,
857
+ text: string,
858
+ x: number,
859
+ y: number,
860
+ maxW: number,
861
+ rowH: number,
862
+ ctx: RenderContext
863
+ ): void {
864
+ const { palette } = ctx;
865
+ const trimmed = text.trim();
866
+
867
+ // Button pattern: `(Label)` or `(Label) | state`
868
+ const btnMatch = trimmed.match(/^\(([^)]+)\)(?:\s*\|\s*(.+))?$/);
869
+ if (btnMatch) {
870
+ const label = btnMatch[1];
871
+ const stateStr = btnMatch[2]?.trim().toLowerCase();
872
+ const isGhost = stateStr === 'ghost';
873
+ const isDestructive = stateStr === 'destructive';
874
+ const fill = isDestructive ? palette.destructive : palette.primary;
875
+ const btnW = Math.min(label.length * 7 + 16, maxW);
876
+ const btnH = 20;
877
+ const by = y + (rowH - btnH) / 2;
878
+
879
+ if (isGhost) {
880
+ g.append('rect')
881
+ .attr('x', x)
882
+ .attr('y', by)
883
+ .attr('width', btnW)
884
+ .attr('height', btnH)
885
+ .attr('fill', 'none')
886
+ .attr('stroke', fill)
887
+ .attr('stroke-width', 1)
888
+ .attr('rx', 3);
889
+ g.append('text')
890
+ .attr('x', x + btnW / 2)
891
+ .attr('y', by + btnH / 2 + 3)
892
+ .attr('fill', fill)
893
+ .attr('font-size', 10)
894
+ .attr('font-weight', '600')
895
+ .attr('text-anchor', 'middle')
896
+ .text(label);
897
+ } else {
898
+ g.append('rect')
899
+ .attr('x', x)
900
+ .attr('y', by)
901
+ .attr('width', btnW)
902
+ .attr('height', btnH)
903
+ .attr('fill', fill)
904
+ .attr('rx', 3);
905
+ g.append('text')
906
+ .attr('x', x + btnW / 2)
907
+ .attr('y', by + btnH / 2 + 3)
908
+ .attr('fill', palette.bg)
909
+ .attr('font-size', 10)
910
+ .attr('font-weight', '600')
911
+ .attr('text-anchor', 'middle')
912
+ .text(label);
913
+ }
914
+ return;
915
+ }
916
+
917
+ // Checkbox pattern: `<x>` or `< >`
918
+ if (/^<\s*x?\s*>$/.test(trimmed)) {
919
+ const checked = /x/i.test(trimmed);
920
+ const boxSize = 12;
921
+ const bx = x;
922
+ const by = y + (rowH - boxSize) / 2;
923
+ g.append('rect')
924
+ .attr('x', bx)
925
+ .attr('y', by)
926
+ .attr('width', boxSize)
927
+ .attr('height', boxSize)
928
+ .attr('fill', checked ? palette.primary : 'none')
929
+ .attr('stroke', checked ? palette.primary : palette.border)
930
+ .attr('rx', 2);
931
+ if (checked) {
932
+ g.append('path')
933
+ .attr(
934
+ 'd',
935
+ `M${bx + 2},${by + boxSize / 2} L${bx + 4},${by + boxSize / 2 + 2} L${bx + 10},${by + 2}`
936
+ )
937
+ .attr('fill', 'none')
938
+ .attr('stroke', palette.bg)
939
+ .attr('stroke-width', 1.5);
940
+ }
941
+ return;
942
+ }
943
+
944
+ // Plain text
945
+ g.append('text')
946
+ .attr('x', x)
947
+ .attr('y', y + rowH / 2 + 4)
948
+ .attr('fill', palette.text)
949
+ .attr('font-size', 12)
950
+ .text(trimmed);
951
+ }
952
+
953
+ function renderImage(
954
+ g: GSelection,
955
+ node: WireframeLayoutNode,
956
+ ctx: RenderContext
957
+ ): void {
958
+ const { palette, isTransparent } = ctx;
959
+ const el = node.element;
960
+ const isRound = el.imageHint === 'round';
961
+
962
+ if (isRound) {
963
+ const r = Math.min(node.width, node.height) / 2;
964
+ const cx = node.width / 2;
965
+ const cy = node.height / 2;
966
+
967
+ g.append('circle')
968
+ .attr('cx', cx)
969
+ .attr('cy', cy)
970
+ .attr('r', r)
971
+ .attr(
972
+ 'fill',
973
+ isTransparent ? 'none' : mix(palette.border, palette.bg, 30)
974
+ )
975
+ .attr('stroke', palette.border);
976
+
977
+ // Diagonal cross
978
+ g.append('line')
979
+ .attr('x1', cx - r * 0.7)
980
+ .attr('y1', cy - r * 0.7)
981
+ .attr('x2', cx + r * 0.7)
982
+ .attr('y2', cy + r * 0.7)
983
+ .attr('stroke', palette.border);
984
+ g.append('line')
985
+ .attr('x1', cx + r * 0.7)
986
+ .attr('y1', cy - r * 0.7)
987
+ .attr('x2', cx - r * 0.7)
988
+ .attr('y2', cy + r * 0.7)
989
+ .attr('stroke', palette.border);
990
+ } else {
991
+ g.append('rect')
992
+ .attr('width', node.width)
993
+ .attr('height', node.height)
994
+ .attr(
995
+ 'fill',
996
+ isTransparent ? 'none' : mix(palette.border, palette.bg, 30)
997
+ )
998
+ .attr('stroke', palette.border)
999
+ .attr('rx', 4);
1000
+
1001
+ // Diagonal cross
1002
+ g.append('line')
1003
+ .attr('x1', 0)
1004
+ .attr('y1', 0)
1005
+ .attr('x2', node.width)
1006
+ .attr('y2', node.height)
1007
+ .attr('stroke', palette.border);
1008
+ g.append('line')
1009
+ .attr('x1', node.width)
1010
+ .attr('y1', 0)
1011
+ .attr('x2', 0)
1012
+ .attr('y2', node.height)
1013
+ .attr('stroke', palette.border);
1014
+ }
1015
+ }
1016
+
1017
+ function renderSkeletonBlock(
1018
+ g: GSelection,
1019
+ node: WireframeLayoutNode,
1020
+ ctx: RenderContext
1021
+ ): void {
1022
+ // Skeleton block: render children as grey placeholders
1023
+ for (const child of node.children) {
1024
+ renderSkeletonPlaceholder(g, child, ctx);
1025
+ }
1026
+ }
1027
+
1028
+ function renderSkeletonPlaceholder(
1029
+ g: GSelection,
1030
+ node: WireframeLayoutNode,
1031
+ ctx: RenderContext
1032
+ ): void {
1033
+ const { palette, isTransparent } = ctx;
1034
+ const pg = g.append('g').attr('transform', `translate(${node.x}, ${node.y})`);
1035
+
1036
+ if (isTransparent) {
1037
+ pg.append('rect')
1038
+ .attr('width', node.width)
1039
+ .attr('height', node.height)
1040
+ .attr('fill', palette.border)
1041
+ .attr('opacity', 0.1)
1042
+ .attr('rx', 4);
1043
+ } else {
1044
+ pg.append('rect')
1045
+ .attr('width', node.width)
1046
+ .attr('height', node.height)
1047
+ .attr('fill', palette.border)
1048
+ .attr('opacity', 0.2)
1049
+ .attr('rx', 4);
1050
+ }
1051
+ }
1052
+
1053
+ function renderAlert(
1054
+ g: GSelection,
1055
+ node: WireframeLayoutNode,
1056
+ ctx: RenderContext
1057
+ ): void {
1058
+ const { palette, isTransparent } = ctx;
1059
+ const el = node.element;
1060
+ const color = getElementSemanticColor(el, palette) || palette.accent;
1061
+
1062
+ // Background
1063
+ if (!isTransparent) {
1064
+ g.append('rect')
1065
+ .attr('width', node.width)
1066
+ .attr('height', node.height)
1067
+ .attr('fill', mix(color, palette.bg, 15))
1068
+ .attr('rx', 4);
1069
+ }
1070
+
1071
+ // Left color bar
1072
+ g.append('rect')
1073
+ .attr('width', 3)
1074
+ .attr('height', node.height)
1075
+ .attr('fill', color)
1076
+ .attr('rx', 1.5);
1077
+
1078
+ // Border for transparent
1079
+ if (isTransparent) {
1080
+ g.append('rect')
1081
+ .attr('width', node.width)
1082
+ .attr('height', node.height)
1083
+ .attr('fill', 'none')
1084
+ .attr('stroke', color)
1085
+ .attr('rx', 4);
1086
+ }
1087
+
1088
+ g.append('text')
1089
+ .attr('x', 12)
1090
+ .attr('y', node.height / 2 + 4)
1091
+ .attr('fill', palette.text)
1092
+ .attr('font-size', 13)
1093
+ .text(el.label);
1094
+ }
1095
+
1096
+ function renderProgress(
1097
+ g: GSelection,
1098
+ node: WireframeLayoutNode,
1099
+ ctx: RenderContext
1100
+ ): void {
1101
+ const { palette, isTransparent } = ctx;
1102
+ const el = node.element;
1103
+ const val = el.progressValue ?? 0;
1104
+ const barH = 8;
1105
+ const cy = node.height / 2;
1106
+
1107
+ // Track
1108
+ g.append('rect')
1109
+ .attr('width', node.width - 40)
1110
+ .attr('height', barH)
1111
+ .attr('y', cy - barH / 2)
1112
+ .attr('fill', isTransparent ? 'none' : mix(palette.border, palette.bg, 30))
1113
+ .attr('stroke', isTransparent ? palette.border : 'none')
1114
+ .attr('rx', barH / 2);
1115
+
1116
+ // Fill
1117
+ const fillW = ((node.width - 40) * val) / 100;
1118
+ if (fillW > 0) {
1119
+ g.append('rect')
1120
+ .attr('width', fillW)
1121
+ .attr('height', barH)
1122
+ .attr('y', cy - barH / 2)
1123
+ .attr('fill', palette.primary)
1124
+ .attr('rx', barH / 2);
1125
+ }
1126
+
1127
+ // Label
1128
+ g.append('text')
1129
+ .attr('x', node.width - 32)
1130
+ .attr('y', cy + 4)
1131
+ .attr('fill', palette.textMuted)
1132
+ .attr('font-size', 11)
1133
+ .text(`${val}%`);
1134
+ }
1135
+
1136
+ function renderChart(
1137
+ g: GSelection,
1138
+ node: WireframeLayoutNode,
1139
+ ctx: RenderContext
1140
+ ): void {
1141
+ const { palette, isTransparent } = ctx;
1142
+ const el = node.element;
1143
+ const hint = el.chartHint || 'line';
1144
+
1145
+ // Border
1146
+ g.append('rect')
1147
+ .attr('width', node.width)
1148
+ .attr('height', node.height)
1149
+ .attr(
1150
+ 'fill',
1151
+ isTransparent ? 'none' : mix(palette.textMuted, palette.bg, 8)
1152
+ )
1153
+ .attr('stroke', palette.border)
1154
+ .attr('rx', 4);
1155
+
1156
+ const padX = 20;
1157
+ const padY = 20;
1158
+ const innerW = node.width - padX * 2;
1159
+ const innerH = node.height - padY * 2;
1160
+
1161
+ if (hint === 'line') {
1162
+ // Wavy line
1163
+ const points = [
1164
+ [0, innerH * 0.7],
1165
+ [innerW * 0.2, innerH * 0.4],
1166
+ [innerW * 0.4, innerH * 0.6],
1167
+ [innerW * 0.6, innerH * 0.2],
1168
+ [innerW * 0.8, innerH * 0.3],
1169
+ [innerW, innerH * 0.1],
1170
+ ];
1171
+ const path = points
1172
+ .map(([x, y], i) =>
1173
+ i === 0 ? `M${padX + x},${padY + y}` : `L${padX + x},${padY + y}`
1174
+ )
1175
+ .join(' ');
1176
+ g.append('path')
1177
+ .attr('d', path)
1178
+ .attr('fill', 'none')
1179
+ .attr('stroke', palette.primary)
1180
+ .attr('stroke-width', 2);
1181
+ } else if (hint === 'bar') {
1182
+ // Vertical bars
1183
+ const barCount = 5;
1184
+ const barW = innerW / (barCount * 2);
1185
+ const barHeights = [0.6, 0.8, 0.5, 0.9, 0.7];
1186
+ for (let i = 0; i < barCount; i++) {
1187
+ const bh = innerH * barHeights[i];
1188
+ g.append('rect')
1189
+ .attr('x', padX + i * (innerW / barCount) + barW / 2)
1190
+ .attr('y', padY + innerH - bh)
1191
+ .attr('width', barW)
1192
+ .attr('height', bh)
1193
+ .attr('fill', palette.primary)
1194
+ .attr('opacity', 0.7)
1195
+ .attr('rx', 2);
1196
+ }
1197
+ } else if (hint === 'pie') {
1198
+ // Circle with wedges
1199
+ const cx = node.width / 2;
1200
+ const cy = node.height / 2;
1201
+ const r = Math.min(innerW, innerH) / 2;
1202
+ const slices = [0.35, 0.25, 0.2, 0.2];
1203
+ const colors = [
1204
+ palette.primary,
1205
+ palette.secondary,
1206
+ palette.accent,
1207
+ palette.border,
1208
+ ];
1209
+ let startAngle = 0;
1210
+ for (let i = 0; i < slices.length; i++) {
1211
+ const endAngle = startAngle + slices[i] * Math.PI * 2;
1212
+ const x1 = cx + r * Math.cos(startAngle);
1213
+ const y1 = cy + r * Math.sin(startAngle);
1214
+ const x2 = cx + r * Math.cos(endAngle);
1215
+ const y2 = cy + r * Math.sin(endAngle);
1216
+ const largeArc = slices[i] > 0.5 ? 1 : 0;
1217
+ g.append('path')
1218
+ .attr(
1219
+ 'd',
1220
+ `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc},1 ${x2},${y2} Z`
1221
+ )
1222
+ .attr('fill', colors[i])
1223
+ .attr('opacity', 0.7);
1224
+ startAngle = endAngle;
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ function renderModal(
1230
+ parent: GSelection,
1231
+ node: WireframeLayoutNode,
1232
+ ctx: RenderContext
1233
+ ): void {
1234
+ const { palette, isTransparent } = ctx;
1235
+ const el = node.element;
1236
+
1237
+ const g = parent
1238
+ .append('g')
1239
+ .attr('transform', `translate(${node.x}, ${node.y})`)
1240
+ .attr('data-line-number', el.lineNumber);
1241
+
1242
+ // Shadow
1243
+ if (!isTransparent) {
1244
+ g.append('rect')
1245
+ .attr('x', 4)
1246
+ .attr('y', 4)
1247
+ .attr('width', node.width)
1248
+ .attr('height', node.height)
1249
+ .attr('fill', 'rgba(0,0,0,0.1)')
1250
+ .attr('rx', GROUP_CORNER);
1251
+ }
1252
+
1253
+ // Modal panel
1254
+ g.append('rect')
1255
+ .attr('width', node.width)
1256
+ .attr('height', node.height)
1257
+ .attr('fill', isTransparent ? 'none' : palette.surface)
1258
+ .attr('stroke', palette.border)
1259
+ .attr('rx', GROUP_CORNER);
1260
+
1261
+ // Title bar
1262
+ g.append('rect')
1263
+ .attr('width', node.width)
1264
+ .attr('height', 36)
1265
+ .attr(
1266
+ 'fill',
1267
+ isTransparent ? 'none' : mix(palette.textMuted, palette.bg, 15)
1268
+ )
1269
+ .attr('rx', GROUP_CORNER);
1270
+
1271
+ g.append('text')
1272
+ .attr('x', 12)
1273
+ .attr('y', 24)
1274
+ .attr('fill', palette.text)
1275
+ .attr('font-size', 14)
1276
+ .attr('font-weight', '600')
1277
+ .text(el.label || 'Modal');
1278
+
1279
+ // Close button
1280
+ const cx = node.width - 16;
1281
+ g.append('text')
1282
+ .attr('x', cx)
1283
+ .attr('y', 24)
1284
+ .attr('fill', palette.textMuted)
1285
+ .attr('font-size', 16)
1286
+ .text('×');
1287
+
1288
+ // Render children with offset
1289
+ for (const child of node.children) {
1290
+ const childG = g.append('g').attr('transform', 'translate(0, 36)');
1291
+ renderNode(childG, child, ctx, 1);
1292
+ }
1293
+ }