@diagrammo/dgmo 0.8.21 → 0.8.23

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 (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,1112 @@
1
+ import * as d3Selection from 'd3-selection';
2
+ import { FONT_FAMILY } from '../fonts';
3
+ import { mix } from '../palettes/color-utils';
4
+ import type { PaletteColors } from '../palettes';
5
+ import type { D3ExportDimensions } from '../utils/d3-types';
6
+ import type {
7
+ ParsedTechRadar,
8
+ QuadrantPosition,
9
+ TechRadarRenderOptions,
10
+ } from './types';
11
+ import { getQuadrantArc } from './layout';
12
+ import {
13
+ resolveQuadrantColor,
14
+ renderTrendIndicator,
15
+ createTooltip,
16
+ DIM_OPACITY,
17
+ } from './shared';
18
+ import { parseInlineMarkdown } from '../utils/inline-markdown';
19
+
20
+ // ============================================================
21
+ // Constants
22
+ // ============================================================
23
+
24
+ const BLIP_RADIUS = 13;
25
+ const BLIP_FONT_SIZE = 10;
26
+ const TITLE_FONT_SIZE = 16;
27
+ const NARROW_BREAKPOINT = 600;
28
+
29
+ // ============================================================
30
+ // Quadrant Focus Renderer
31
+ // ============================================================
32
+
33
+ export function renderQuadrantFocus(
34
+ container: HTMLDivElement,
35
+ parsed: ParsedTechRadar,
36
+ quadrantPosition: QuadrantPosition,
37
+ palette: PaletteColors,
38
+ isDark: boolean,
39
+ onClickItem?: (lineNumber: number) => void,
40
+ exportDims?: D3ExportDimensions,
41
+ _options?: TechRadarRenderOptions
42
+ ): void {
43
+ const quadrant = parsed.quadrants.find(
44
+ (q) => q.position === quadrantPosition
45
+ );
46
+ if (!quadrant) return;
47
+
48
+ // Clear container
49
+ container.innerHTML = '';
50
+
51
+ const width = exportDims?.width ?? container.clientWidth;
52
+ const height = exportDims?.height ?? container.clientHeight;
53
+ if (width <= 0 || height <= 0) return;
54
+
55
+ const isNarrow = width < NARROW_BREAKPOINT;
56
+ const qColor = resolveQuadrantColor(
57
+ quadrant.position,
58
+ quadrant.color,
59
+ palette
60
+ );
61
+
62
+ // ── Breadcrumb title ──
63
+ const titleBar = document.createElement('div');
64
+ titleBar.style.cssText = `
65
+ display: flex; align-items: baseline; gap: 8px;
66
+ padding: 8px 12px; font-family: ${FONT_FAMILY};
67
+ font-size: ${TITLE_FONT_SIZE}px; background: ${palette.bg};
68
+ `;
69
+
70
+ const titleLink = document.createElement('span');
71
+ titleLink.textContent = parsed.title || 'Tech Radar';
72
+ titleLink.style.cssText = `font-weight: bold; color: ${palette.text}; cursor: pointer;`;
73
+ titleLink.setAttribute('data-line-number', String(parsed.titleLineNumber));
74
+
75
+ const sep = document.createElement('span');
76
+ sep.textContent = '›';
77
+ sep.style.color = palette.border;
78
+
79
+ const quadrantLabel = document.createElement('span');
80
+ quadrantLabel.textContent = quadrant.name;
81
+ quadrantLabel.style.cssText = `font-weight: bold; color: ${qColor};`;
82
+
83
+ titleBar.appendChild(titleLink);
84
+ titleBar.appendChild(sep);
85
+ titleBar.appendChild(quadrantLabel);
86
+ container.appendChild(titleBar);
87
+
88
+ // ── Main layout: SVG radar (left) + HTML panel (right) ──
89
+ const mainLayout = document.createElement('div');
90
+ mainLayout.style.cssText = `
91
+ display: flex; flex-direction: ${isNarrow ? 'column' : 'row'};
92
+ flex: 1; min-height: 0; height: calc(100% - 40px); background: ${palette.bg};
93
+ `;
94
+ container.appendChild(mainLayout);
95
+
96
+ // SVG container for quarter-circle
97
+ const svgContainer = document.createElement('div');
98
+ svgContainer.style.cssText = `
99
+ ${isNarrow ? 'height: 40%;' : 'width: 50%; min-width: 200px;'}
100
+ flex-shrink: 0;
101
+ `;
102
+ mainLayout.appendChild(svgContainer);
103
+
104
+ // HTML panel for blip listing
105
+ const panel = document.createElement('div');
106
+ panel.style.cssText = `
107
+ flex: 1; overflow-y: auto; padding: 8px 12px;
108
+ font-family: ${FONT_FAMILY}; background: ${palette.bg};
109
+ `;
110
+ mainLayout.appendChild(panel);
111
+
112
+ // ── Render HTML side panel first (returns toggle callback for radar clicks) ──
113
+ const toggleBlip = renderHtmlPanel(
114
+ panel,
115
+ parsed,
116
+ quadrant,
117
+ qColor,
118
+ palette,
119
+ isDark,
120
+ container,
121
+ onClickItem
122
+ );
123
+
124
+ // ── Render quarter-circle SVG ──
125
+ const svgWidth = svgContainer.clientWidth || (isNarrow ? width : width * 0.5);
126
+ const svgHeight =
127
+ svgContainer.clientHeight || (isNarrow ? height * 0.4 : height - 40);
128
+
129
+ const svg = d3Selection
130
+ .select(svgContainer)
131
+ .append('svg')
132
+ .attr('width', '100%')
133
+ .attr('height', '100%')
134
+ .attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`)
135
+ .style('background', palette.bg);
136
+
137
+ const tooltip = createTooltip(container, palette, isDark);
138
+
139
+ renderQuarterCircle(
140
+ svg,
141
+ parsed,
142
+ quadrant,
143
+ qColor,
144
+ palette,
145
+ isDark,
146
+ svgWidth,
147
+ svgHeight,
148
+ palette.border,
149
+ tooltip,
150
+ container,
151
+ toggleBlip
152
+ );
153
+
154
+ // ── Click handlers for title (back navigation) ──
155
+ titleLink.addEventListener('click', () => {
156
+ if (onClickItem) onClickItem(parsed.titleLineNumber);
157
+ });
158
+
159
+ // ── Active line from editor cursor → expand that blip in the panel ──
160
+ if (_options?.activeLine) {
161
+ const activeLn = _options.activeLine;
162
+ for (const blip of quadrant.blips) {
163
+ const isOnBlip = blip.lineNumber === activeLn;
164
+ const isOnDesc =
165
+ blip.description.length > 0 &&
166
+ activeLn > blip.lineNumber &&
167
+ activeLn <= blip.lineNumber + blip.description.length;
168
+ if (isOnBlip || isOnDesc) {
169
+ toggleBlip(blip.lineNumber);
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // ============================================================
177
+ // Quarter-Circle SVG Rendering
178
+ // ============================================================
179
+
180
+ function renderQuarterCircle(
181
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
182
+ parsed: ParsedTechRadar,
183
+ quadrant: ParsedTechRadar['quadrants'][number],
184
+ qColor: string,
185
+ palette: PaletteColors,
186
+ isDark: boolean,
187
+ width: number,
188
+ height: number,
189
+ mutedColor: string,
190
+ tooltip: HTMLDivElement,
191
+ rootContainer: HTMLElement,
192
+ onClickItem?: (lineNumber: number) => void
193
+ ): void {
194
+ const padding = 8;
195
+ const size = Math.min(width - padding, height - padding);
196
+ const maxRadius = size * 0.95;
197
+ const ringCount = parsed.rings.length;
198
+ const ringBandWidth = maxRadius / ringCount;
199
+
200
+ const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
201
+ let cx: number, cy: number;
202
+
203
+ switch (quadrant.position) {
204
+ case 'top-right':
205
+ cx = padding;
206
+ cy = size + padding;
207
+ break;
208
+ case 'top-left':
209
+ cx = width - padding;
210
+ cy = size + padding;
211
+ break;
212
+ case 'bottom-left':
213
+ cx = width - padding;
214
+ cy = padding;
215
+ break;
216
+ case 'bottom-right':
217
+ cx = padding;
218
+ cy = padding;
219
+ break;
220
+ }
221
+
222
+ // Ring arcs with zebra shading
223
+ const arcGen = (innerR: number, outerR: number) =>
224
+ `M${cx + outerR * Math.cos(startAngle)},${cy - outerR * Math.sin(startAngle)} A${outerR},${outerR} 0 0,0 ${cx + outerR * Math.cos(endAngle)},${cy - outerR * Math.sin(endAngle)} L${cx + innerR * Math.cos(endAngle)},${cy - innerR * Math.sin(endAngle)} A${innerR},${innerR} 0 0,1 ${cx + innerR * Math.cos(startAngle)},${cy - innerR * Math.sin(startAngle)} Z`;
225
+
226
+ for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
227
+ const innerR = ri * ringBandWidth;
228
+ const outerR = (ri + 1) * ringBandWidth;
229
+ const fillColor =
230
+ ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
231
+
232
+ const ringName = parsed.rings[ri].name;
233
+
234
+ // Background ring arc
235
+ svg
236
+ .append('path')
237
+ .attr('d', arcGen(innerR, outerR))
238
+ .attr('fill', fillColor)
239
+ .attr('stroke', mutedColor)
240
+ .attr('stroke-width', 0.5);
241
+
242
+ // Transparent hover overlay for ring interaction
243
+ svg
244
+ .append('path')
245
+ .attr('d', arcGen(innerR, outerR))
246
+ .attr('fill', 'transparent')
247
+ .attr('data-ring-arc', ringName)
248
+ .style('cursor', 'pointer')
249
+ .on('mouseenter', () => {
250
+ // Tint the hovered ring arc
251
+ d3Selection
252
+ .select(rootContainer)
253
+ .selectAll<SVGPathElement, unknown>('[data-ring-arc]')
254
+ .each(function () {
255
+ const el = d3Selection.select(this);
256
+ const isMatch = this.getAttribute('data-ring-arc') === ringName;
257
+ el.attr('fill', isMatch ? qColor : 'transparent').attr(
258
+ 'opacity',
259
+ isMatch ? 0.15 : 1
260
+ );
261
+ });
262
+ dimExceptRing(rootContainer, ringName);
263
+ })
264
+ .on('mouseleave', () => {
265
+ d3Selection
266
+ .select(rootContainer)
267
+ .selectAll<SVGPathElement, unknown>('[data-ring-arc]')
268
+ .attr('fill', 'transparent')
269
+ .attr('opacity', 1);
270
+ clearDim(rootContainer);
271
+ });
272
+ }
273
+
274
+ // Ring labels removed — the side panel ring headers serve this purpose
275
+
276
+ // Blip dots
277
+ const ringOrder = parsed.rings.map((r) => r.name);
278
+ const angularPadding = 0.08;
279
+ const radialPadding = ringBandWidth * 0.12;
280
+ const usableArcStart = startAngle + angularPadding;
281
+ const usableArcEnd = endAngle - angularPadding;
282
+ const arcSpan = usableArcEnd - usableArcStart;
283
+
284
+ const blipsByRing = new Map<string, typeof quadrant.blips>();
285
+ for (const blip of quadrant.blips) {
286
+ const list = blipsByRing.get(blip.ring) ?? [];
287
+ list.push(blip);
288
+ blipsByRing.set(blip.ring, list);
289
+ }
290
+
291
+ for (const [ringName, blips] of blipsByRing) {
292
+ const ringIndex = ringOrder.indexOf(ringName);
293
+ if (ringIndex < 0) continue;
294
+ const rInner = ringIndex * ringBandWidth + radialPadding;
295
+ const rOuter = (ringIndex + 1) * ringBandWidth - radialPadding;
296
+ const rMid = (rInner + rOuter) / 2;
297
+
298
+ for (let bi = 0; bi < blips.length; bi++) {
299
+ const blip = blips[bi];
300
+ const angle =
301
+ blips.length === 1
302
+ ? (usableArcStart + usableArcEnd) / 2
303
+ : usableArcStart + ((bi + 0.5) / blips.length) * arcSpan;
304
+
305
+ const radius =
306
+ blips.length <= 3
307
+ ? rMid
308
+ : rInner +
309
+ BLIP_RADIUS +
310
+ ((bi % 3) / 2) * (rOuter - rInner - BLIP_RADIUS * 2);
311
+
312
+ const bx = cx + radius * Math.cos(angle);
313
+ const by = cy - radius * Math.sin(angle);
314
+
315
+ const blipGroup = svg
316
+ .append('g')
317
+ .attr('data-line-number', blip.lineNumber)
318
+ .attr('data-ring', blip.ring)
319
+ .attr('data-trend', blip.trend ?? 'stable')
320
+ .style('cursor', 'pointer');
321
+
322
+ const angleToCenter = Math.atan2(cy - by, cx - bx);
323
+ renderTrendIndicator(
324
+ blipGroup,
325
+ blip.trend,
326
+ qColor,
327
+ bx,
328
+ by,
329
+ BLIP_RADIUS,
330
+ angleToCenter
331
+ );
332
+
333
+ blipGroup
334
+ .append('text')
335
+ .attr('x', bx)
336
+ .attr('y', by + 3)
337
+ .attr('text-anchor', 'middle')
338
+ .attr('fill', isDark ? '#000' : '#fff')
339
+ .attr('font-family', FONT_FAMILY)
340
+ .attr('font-size', BLIP_FONT_SIZE)
341
+ .attr('font-weight', 'bold')
342
+ .text(blip.globalNumber);
343
+
344
+ // Hover: scale up + dim others (preview only, no expansion)
345
+ const lineNum = String(blip.lineNumber);
346
+ blipGroup
347
+ .on('mouseenter', () => {
348
+ blipGroup.attr(
349
+ 'transform',
350
+ `translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
351
+ );
352
+ dimExcept(rootContainer, lineNum);
353
+ })
354
+ .on('mouseleave', () => {
355
+ blipGroup.attr('transform', null);
356
+ clearDim(rootContainer);
357
+ });
358
+
359
+ // Click: expand description in panel (persistent)
360
+ blipGroup.on('click', () => {
361
+ if (onClickItem) {
362
+ onClickItem(blip.lineNumber);
363
+ requestAnimationFrame(() => dimExcept(rootContainer, lineNum));
364
+ }
365
+ });
366
+ }
367
+ }
368
+ }
369
+
370
+ // ============================================================
371
+ // Cross-highlight helpers (work across SVG + HTML)
372
+ // ============================================================
373
+
374
+ function dimExcept(root: HTMLElement, lineNum: string): void {
375
+ root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
376
+ el.style.opacity =
377
+ el.getAttribute('data-line-number') === lineNum
378
+ ? '1'
379
+ : String(DIM_OPACITY);
380
+ });
381
+ }
382
+
383
+ function dimExceptRing(root: HTMLElement, ringName: string): void {
384
+ // Dim blips not in the hovered ring (SVG + HTML)
385
+ root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
386
+ el.style.opacity =
387
+ el.getAttribute('data-ring') === ringName ? '1' : String(DIM_OPACITY);
388
+ });
389
+ // Dim ring groups not matching
390
+ root.querySelectorAll<HTMLElement>('[data-ring-group]').forEach((el) => {
391
+ el.style.opacity =
392
+ el.getAttribute('data-ring-group') === ringName
393
+ ? '1'
394
+ : String(DIM_OPACITY);
395
+ });
396
+ }
397
+
398
+ function clearDim(root: HTMLElement): void {
399
+ root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
400
+ el.style.opacity = '1';
401
+ });
402
+ root.querySelectorAll<HTMLElement>('[data-ring-group]').forEach((el) => {
403
+ el.style.opacity = '1';
404
+ });
405
+ }
406
+
407
+ // ============================================================
408
+ // HTML Side Panel
409
+ // ============================================================
410
+
411
+ /**
412
+ * Render the HTML side panel. Returns a toggle callback that the radar
413
+ * can call to expand/scroll a blip by line number.
414
+ */
415
+ function renderHtmlPanel(
416
+ panel: HTMLElement,
417
+ parsed: ParsedTechRadar,
418
+ quadrant: ParsedTechRadar['quadrants'][number],
419
+ qColor: string,
420
+ palette: PaletteColors,
421
+ isDark: boolean,
422
+ rootContainer: HTMLElement,
423
+ onClickItem?: (lineNumber: number) => void
424
+ ): (lineNumber: number) => void {
425
+ const ringOrder = parsed.rings.map((r) => r.name);
426
+ const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
427
+ let expandedLineNum: string | null = null;
428
+
429
+ function render() {
430
+ panel.innerHTML = '';
431
+
432
+ for (const ringName of ringOrder) {
433
+ const blips = quadrant.blips.filter((b) => b.ring === ringName);
434
+ if (blips.length === 0) continue;
435
+
436
+ // Ring group container
437
+ const ringGroup = document.createElement('div');
438
+ ringGroup.setAttribute('data-ring-group', ringName);
439
+ ringGroup.style.cssText = `
440
+ background: ${palette.surface};
441
+ border-radius: 8px;
442
+ padding: 10px;
443
+ margin-bottom: 12px;
444
+ transition: opacity 0.15s;
445
+ `;
446
+
447
+ // Ring header inside the group
448
+ const header = document.createElement('div');
449
+ header.style.cssText = `
450
+ font-size: 13px; font-weight: 700; color: ${palette.textMuted};
451
+ margin-bottom: 8px;
452
+ `;
453
+ header.textContent = ringName;
454
+ ringGroup.appendChild(header);
455
+
456
+ panel.appendChild(ringGroup);
457
+
458
+ // Blip nodes (appended to ringGroup, not panel)
459
+ for (const blip of blips) {
460
+ const ln = String(blip.lineNumber);
461
+ const isExpanded = expandedLineNum === ln;
462
+ const hasDesc = blip.description.length > 0;
463
+
464
+ const node = document.createElement('div');
465
+ node.setAttribute('data-line-number', ln);
466
+ node.setAttribute('data-ring', blip.ring);
467
+ node.setAttribute('data-trend', blip.trend ?? 'stable');
468
+ node.style.cssText = `
469
+ background: ${fillColor}; border: 1.5px solid ${qColor};
470
+ border-radius: 6px; margin-bottom: 6px; cursor: pointer;
471
+ transition: border-width 0.1s;
472
+ ${isExpanded ? 'border-width: 2px;' : ''}
473
+ `;
474
+
475
+ // Title row
476
+ const titleRow = document.createElement('div');
477
+ titleRow.style.cssText = `
478
+ display: flex; align-items: center; gap: 8px;
479
+ padding: 6px 10px; min-height: 28px;
480
+ `;
481
+
482
+ // Mini SVG blip indicator
483
+ const indicatorSvg = document.createElementNS(
484
+ 'http://www.w3.org/2000/svg',
485
+ 'svg'
486
+ );
487
+ indicatorSvg.setAttribute('width', '26');
488
+ indicatorSvg.setAttribute('height', '26');
489
+ indicatorSvg.style.flexShrink = '0';
490
+ const indicatorG = d3Selection
491
+ .select(indicatorSvg)
492
+ .append('g') as d3Selection.Selection<
493
+ SVGGElement,
494
+ unknown,
495
+ null,
496
+ undefined
497
+ >;
498
+ renderTrendIndicator(
499
+ indicatorG,
500
+ blip.trend,
501
+ qColor,
502
+ 13,
503
+ 13,
504
+ 10,
505
+ -Math.PI / 2
506
+ );
507
+ d3Selection
508
+ .select(indicatorSvg)
509
+ .append('text')
510
+ .attr('x', 13)
511
+ .attr('y', 16)
512
+ .attr('text-anchor', 'middle')
513
+ .attr('fill', isDark ? '#000' : '#fff')
514
+ .attr('font-family', FONT_FAMILY)
515
+ .attr('font-size', 9)
516
+ .attr('font-weight', 'bold')
517
+ .text(blip.globalNumber);
518
+ titleRow.appendChild(indicatorSvg);
519
+
520
+ // Name
521
+ const name = document.createElement('span');
522
+ name.textContent = blip.name;
523
+ name.style.cssText = `
524
+ flex: 1; font-size: 12px; font-weight: 600;
525
+ color: ${palette.text}; white-space: nowrap;
526
+ overflow: hidden; text-overflow: ellipsis;
527
+ `;
528
+ titleRow.appendChild(name);
529
+
530
+ node.appendChild(titleRow);
531
+
532
+ // Description (expanded only)
533
+ if (isExpanded && hasDesc) {
534
+ const sep = document.createElement('div');
535
+ sep.style.cssText = `
536
+ border-top: 1px solid ${qColor}; opacity: 0.3;
537
+ `;
538
+ node.appendChild(sep);
539
+
540
+ const descDiv = document.createElement('div');
541
+ descDiv.style.cssText = `
542
+ padding: 6px 10px 8px; font-size: 11px; line-height: 1.6;
543
+ color: ${palette.textMuted};
544
+ `;
545
+ descDiv.innerHTML = renderDescriptionHtml(blip.description, palette);
546
+ node.appendChild(descDiv);
547
+ }
548
+
549
+ // Hover: dim all other blips (radar + panel), scale up matching radar dot
550
+ node.addEventListener('mouseenter', () => {
551
+ dimExcept(rootContainer, ln);
552
+ // Scale up matching radar dot
553
+ rootContainer
554
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
555
+ .forEach((el) => {
556
+ if (el.getAttribute('data-line-number') === ln) {
557
+ const bbox = (el as SVGGraphicsElement).getBBox?.();
558
+ if (bbox) {
559
+ const bx = bbox.x + bbox.width / 2;
560
+ const by = bbox.y + bbox.height / 2;
561
+ el.setAttribute(
562
+ 'transform',
563
+ `translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
564
+ );
565
+ }
566
+ }
567
+ });
568
+ });
569
+ node.addEventListener('mouseleave', () => {
570
+ clearDim(rootContainer);
571
+ rootContainer
572
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
573
+ .forEach((el) => {
574
+ el.removeAttribute('transform');
575
+ });
576
+ });
577
+
578
+ // Click: accordion toggle + persistent dim
579
+ node.addEventListener('click', (event) => {
580
+ event.stopPropagation();
581
+ if (hasDesc) {
582
+ const wasExpanded = expandedLineNum === ln;
583
+ expandedLineNum = wasExpanded ? null : ln;
584
+ render();
585
+ if (!wasExpanded) {
586
+ // Dim others after re-render
587
+ requestAnimationFrame(() => {
588
+ dimExcept(rootContainer, ln);
589
+ // Scale up matching radar dot
590
+ rootContainer
591
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
592
+ .forEach((el) => {
593
+ if (el.getAttribute('data-line-number') === ln) {
594
+ const bbox = (el as SVGGraphicsElement).getBBox?.();
595
+ if (bbox) {
596
+ const cbx = bbox.x + bbox.width / 2;
597
+ const cby = bbox.y + bbox.height / 2;
598
+ el.setAttribute(
599
+ 'transform',
600
+ `translate(${cbx},${cby}) scale(1.5) translate(${-cbx},${-cby})`
601
+ );
602
+ }
603
+ }
604
+ });
605
+ });
606
+ } else {
607
+ // Collapsed — clear all
608
+ clearDim(rootContainer);
609
+ rootContainer
610
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
611
+ .forEach((el) => el.removeAttribute('transform'));
612
+ }
613
+ } else if (onClickItem) {
614
+ onClickItem(blip.lineNumber);
615
+ }
616
+ });
617
+
618
+ ringGroup.appendChild(node);
619
+ }
620
+ }
621
+ }
622
+
623
+ render();
624
+
625
+ // Click on empty panel space → collapse and clear
626
+ panel.addEventListener('click', () => {
627
+ if (expandedLineNum) {
628
+ expandedLineNum = null;
629
+ render();
630
+ clearDim(rootContainer);
631
+ rootContainer
632
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
633
+ .forEach((el) => el.removeAttribute('transform'));
634
+ }
635
+ });
636
+
637
+ // Return a toggle function for external callers (radar blip clicks)
638
+ return (lineNumber: number) => {
639
+ const ln = String(lineNumber);
640
+ const blip = quadrant.blips.find((b) => String(b.lineNumber) === ln);
641
+ if (blip && blip.description.length > 0) {
642
+ const wasExpanded = expandedLineNum === ln;
643
+ expandedLineNum = wasExpanded ? null : ln;
644
+ render();
645
+ const node = panel.querySelector(`[data-line-number="${ln}"]`);
646
+ if (node) node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
647
+ if (!wasExpanded) {
648
+ requestAnimationFrame(() => dimExcept(rootContainer, ln));
649
+ } else {
650
+ clearDim(rootContainer);
651
+ rootContainer
652
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
653
+ .forEach((el) => el.removeAttribute('transform'));
654
+ }
655
+ } else if (onClickItem) {
656
+ onClickItem(lineNumber);
657
+ }
658
+ };
659
+ }
660
+
661
+ // ============================================================
662
+ // Description HTML Rendering (with markdown)
663
+ // ============================================================
664
+
665
+ function renderDescriptionHtml(
666
+ lines: string[],
667
+ palette: PaletteColors
668
+ ): string {
669
+ // Join prose lines into paragraphs, keep bullets separate
670
+ const paragraphs = joinParagraphs(lines);
671
+ let html = '';
672
+
673
+ for (const para of paragraphs) {
674
+ const trimmed = para.trim();
675
+ const isBullet = /^[-*•]\s+/.test(trimmed);
676
+ const content = isBullet ? trimmed.replace(/^[-*•]\s+/, '') : trimmed;
677
+ const rendered = renderInlineMarkdownHtml(content, palette);
678
+
679
+ if (isBullet) {
680
+ html += `<div style="padding-left: 14px; text-indent: -10px; margin: 2px 0;">• ${rendered}</div>`;
681
+ } else {
682
+ html += `<div style="margin: 4px 0;">${rendered}</div>`;
683
+ }
684
+ }
685
+
686
+ return html;
687
+ }
688
+
689
+ function joinParagraphs(lines: string[]): string[] {
690
+ const result: string[] = [];
691
+ let currentPara = '';
692
+
693
+ for (const line of lines) {
694
+ const trimmed = line.trim();
695
+ const isBullet = /^[-*•]\s+/.test(trimmed);
696
+
697
+ if (isBullet) {
698
+ if (currentPara) {
699
+ result.push(currentPara);
700
+ currentPara = '';
701
+ }
702
+ result.push(trimmed);
703
+ } else if (!trimmed) {
704
+ if (currentPara) {
705
+ result.push(currentPara);
706
+ currentPara = '';
707
+ }
708
+ } else {
709
+ currentPara = currentPara ? `${currentPara} ${trimmed}` : trimmed;
710
+ }
711
+ }
712
+
713
+ if (currentPara) result.push(currentPara);
714
+ return result;
715
+ }
716
+
717
+ function renderInlineMarkdownHtml(
718
+ text: string,
719
+ palette: PaletteColors
720
+ ): string {
721
+ const spans = parseInlineMarkdown(text);
722
+ let html = '';
723
+ for (const span of spans) {
724
+ let t = escapeHtml(span.text);
725
+ if (span.bold) t = `<strong>${t}</strong>`;
726
+ if (span.italic) t = `<em>${t}</em>`;
727
+ if (span.code)
728
+ t = `<code style="background:${palette.surface}; padding: 1px 4px; border-radius: 3px; font-size: 10px;">${t}</code>`;
729
+ if (span.href)
730
+ t = `<a href="${escapeHtml(span.href)}" target="_blank" rel="noopener" style="color: ${palette.primary ?? palette.text}; text-decoration: underline;">${t}</a>`;
731
+ html += t;
732
+ }
733
+ return html;
734
+ }
735
+
736
+ function escapeHtml(text: string): string {
737
+ return text
738
+ .replace(/&/g, '&amp;')
739
+ .replace(/</g, '&lt;')
740
+ .replace(/>/g, '&gt;');
741
+ }
742
+
743
+ // ============================================================
744
+ // Static Quadrant Export Renderer (all descriptions expanded, no interactivity)
745
+ // ============================================================
746
+
747
+ export function renderQuadrantFocusForExport(
748
+ container: HTMLDivElement,
749
+ parsed: ParsedTechRadar,
750
+ quadrantPosition: QuadrantPosition,
751
+ palette: PaletteColors,
752
+ isDark: boolean,
753
+ exportDims: { width: number; height: number }
754
+ ): void {
755
+ const quadrant = parsed.quadrants.find(
756
+ (q) => q.position === quadrantPosition
757
+ );
758
+ if (!quadrant) return;
759
+
760
+ container.innerHTML = '';
761
+
762
+ const width = exportDims.width;
763
+ const height = exportDims.height;
764
+ const qColor = resolveQuadrantColor(
765
+ quadrant.position,
766
+ quadrant.color,
767
+ palette
768
+ );
769
+
770
+ // ── Title bar ──
771
+ const titleBar = document.createElement('div');
772
+ titleBar.style.cssText = `
773
+ display: flex; align-items: baseline; gap: 8px;
774
+ padding: 12px 16px; font-family: ${FONT_FAMILY};
775
+ font-size: ${TITLE_FONT_SIZE + 2}px; background: ${palette.bg};
776
+ `;
777
+
778
+ const titleText = document.createElement('span');
779
+ titleText.textContent = parsed.title || 'Tech Radar';
780
+ titleText.style.cssText = `font-weight: bold; color: ${palette.text};`;
781
+
782
+ const sep = document.createElement('span');
783
+ sep.textContent = '›';
784
+ sep.style.color = palette.border;
785
+
786
+ const quadrantLabel = document.createElement('span');
787
+ quadrantLabel.textContent = quadrant.name;
788
+ quadrantLabel.style.cssText = `font-weight: bold; color: ${qColor};`;
789
+
790
+ titleBar.appendChild(titleText);
791
+ titleBar.appendChild(sep);
792
+ titleBar.appendChild(quadrantLabel);
793
+ container.appendChild(titleBar);
794
+
795
+ // ── Main layout: SVG radar (left) + HTML panel (right) ──
796
+ const mainLayout = document.createElement('div');
797
+ mainLayout.style.cssText = `
798
+ display: flex; flex-direction: row;
799
+ height: ${height - 48}px; background: ${palette.bg};
800
+ `;
801
+ container.appendChild(mainLayout);
802
+
803
+ // SVG container for quarter-circle (left 45%)
804
+ const svgContainer = document.createElement('div');
805
+ svgContainer.style.cssText = `width: 45%; min-width: 200px; flex-shrink: 0;`;
806
+ mainLayout.appendChild(svgContainer);
807
+
808
+ // HTML panel for blip listing (right 55%)
809
+ const panel = document.createElement('div');
810
+ panel.style.cssText = `
811
+ flex: 1; padding: 8px 16px;
812
+ font-family: ${FONT_FAMILY}; background: ${palette.bg};
813
+ `;
814
+ mainLayout.appendChild(panel);
815
+
816
+ // ── Render static HTML panel (all descriptions expanded) ──
817
+ renderStaticHtmlPanel(panel, parsed, quadrant, qColor, palette, isDark);
818
+
819
+ // ── Render quarter-circle SVG ──
820
+ const svgWidth = width * 0.45;
821
+ const svgHeight = height - 48;
822
+
823
+ const svg = d3Selection
824
+ .select(svgContainer)
825
+ .append('svg')
826
+ .attr('width', svgWidth)
827
+ .attr('height', svgHeight)
828
+ .attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`)
829
+ .style('background', palette.bg);
830
+
831
+ renderQuarterCircleStatic(
832
+ svg,
833
+ parsed,
834
+ quadrant,
835
+ qColor,
836
+ palette,
837
+ isDark,
838
+ svgWidth,
839
+ svgHeight,
840
+ palette.border
841
+ );
842
+ }
843
+
844
+ /**
845
+ * Render the quarter-circle SVG without any interactivity.
846
+ */
847
+ function renderQuarterCircleStatic(
848
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
849
+ parsed: ParsedTechRadar,
850
+ quadrant: ParsedTechRadar['quadrants'][number],
851
+ qColor: string,
852
+ palette: PaletteColors,
853
+ isDark: boolean,
854
+ width: number,
855
+ height: number,
856
+ mutedColor: string
857
+ ): void {
858
+ const padding = 8;
859
+ const size = Math.min(width - padding, height - padding);
860
+ const maxRadius = size * 0.95;
861
+ const ringCount = parsed.rings.length;
862
+ const ringBandWidth = maxRadius / ringCount;
863
+
864
+ const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
865
+ let cx: number, cy: number;
866
+
867
+ switch (quadrant.position) {
868
+ case 'top-right':
869
+ cx = padding;
870
+ cy = size + padding;
871
+ break;
872
+ case 'top-left':
873
+ cx = width - padding;
874
+ cy = size + padding;
875
+ break;
876
+ case 'bottom-left':
877
+ cx = width - padding;
878
+ cy = padding;
879
+ break;
880
+ case 'bottom-right':
881
+ cx = padding;
882
+ cy = padding;
883
+ break;
884
+ }
885
+
886
+ // Ring arcs with zebra shading
887
+ const arcGen = (innerR: number, outerR: number) =>
888
+ `M${cx + outerR * Math.cos(startAngle)},${cy - outerR * Math.sin(startAngle)} A${outerR},${outerR} 0 0,0 ${cx + outerR * Math.cos(endAngle)},${cy - outerR * Math.sin(endAngle)} L${cx + innerR * Math.cos(endAngle)},${cy - innerR * Math.sin(endAngle)} A${innerR},${innerR} 0 0,1 ${cx + innerR * Math.cos(startAngle)},${cy - innerR * Math.sin(startAngle)} Z`;
889
+
890
+ for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
891
+ const innerR = ri * ringBandWidth;
892
+ const outerR = (ri + 1) * ringBandWidth;
893
+ const fillColor =
894
+ ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
895
+
896
+ svg
897
+ .append('path')
898
+ .attr('d', arcGen(innerR, outerR))
899
+ .attr('fill', fillColor)
900
+ .attr('stroke', mutedColor)
901
+ .attr('stroke-width', 0.5);
902
+ }
903
+
904
+ // Ring labels along the arc edge
905
+ for (let ri = 0; ri < parsed.rings.length; ri++) {
906
+ const rCenter = (ri + 0.5) * ringBandWidth;
907
+ const midAngle = (startAngle + endAngle) / 2;
908
+ const labelX = cx + rCenter * Math.cos(midAngle);
909
+ const labelY = cy - rCenter * Math.sin(midAngle);
910
+
911
+ svg
912
+ .append('text')
913
+ .attr('x', labelX)
914
+ .attr('y', labelY)
915
+ .attr('text-anchor', 'middle')
916
+ .attr('dominant-baseline', 'central')
917
+ .attr('fill', palette.textMuted)
918
+ .attr('font-family', FONT_FAMILY)
919
+ .attr('font-size', 11)
920
+ .attr('font-weight', '600')
921
+ .attr('opacity', 0.5)
922
+ .text(parsed.rings[ri].name);
923
+ }
924
+
925
+ // Blip dots
926
+ const ringOrder = parsed.rings.map((r) => r.name);
927
+ const angularPadding = 0.08;
928
+ const radialPadding = ringBandWidth * 0.12;
929
+ const usableArcStart = startAngle + angularPadding;
930
+ const usableArcEnd = endAngle - angularPadding;
931
+ const arcSpan = usableArcEnd - usableArcStart;
932
+
933
+ const blipsByRing = new Map<string, typeof quadrant.blips>();
934
+ for (const blip of quadrant.blips) {
935
+ const list = blipsByRing.get(blip.ring) ?? [];
936
+ list.push(blip);
937
+ blipsByRing.set(blip.ring, list);
938
+ }
939
+
940
+ for (const [ringName, blips] of blipsByRing) {
941
+ const ringIndex = ringOrder.indexOf(ringName);
942
+ if (ringIndex < 0) continue;
943
+ const rInner = ringIndex * ringBandWidth + radialPadding;
944
+ const rOuter = (ringIndex + 1) * ringBandWidth - radialPadding;
945
+ const rMid = (rInner + rOuter) / 2;
946
+
947
+ for (let bi = 0; bi < blips.length; bi++) {
948
+ const blip = blips[bi];
949
+ const angle =
950
+ blips.length === 1
951
+ ? (usableArcStart + usableArcEnd) / 2
952
+ : usableArcStart + ((bi + 0.5) / blips.length) * arcSpan;
953
+
954
+ const radius =
955
+ blips.length <= 3
956
+ ? rMid
957
+ : rInner +
958
+ BLIP_RADIUS +
959
+ ((bi % 3) / 2) * (rOuter - rInner - BLIP_RADIUS * 2);
960
+
961
+ const bx = cx + radius * Math.cos(angle);
962
+ const by = cy - radius * Math.sin(angle);
963
+
964
+ const blipGroup = svg.append('g');
965
+
966
+ const angleToCenter = Math.atan2(cy - by, cx - bx);
967
+ renderTrendIndicator(
968
+ blipGroup,
969
+ blip.trend,
970
+ qColor,
971
+ bx,
972
+ by,
973
+ BLIP_RADIUS,
974
+ angleToCenter
975
+ );
976
+
977
+ blipGroup
978
+ .append('text')
979
+ .attr('x', bx)
980
+ .attr('y', by + 3)
981
+ .attr('text-anchor', 'middle')
982
+ .attr('fill', isDark ? '#000' : '#fff')
983
+ .attr('font-family', FONT_FAMILY)
984
+ .attr('font-size', BLIP_FONT_SIZE)
985
+ .attr('font-weight', 'bold')
986
+ .text(blip.globalNumber);
987
+ }
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Render static HTML panel with all descriptions expanded (for export).
993
+ */
994
+ function renderStaticHtmlPanel(
995
+ panel: HTMLElement,
996
+ parsed: ParsedTechRadar,
997
+ quadrant: ParsedTechRadar['quadrants'][number],
998
+ qColor: string,
999
+ palette: PaletteColors,
1000
+ isDark: boolean
1001
+ ): void {
1002
+ const ringOrder = parsed.rings.map((r) => r.name);
1003
+ const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
1004
+
1005
+ for (const ringName of ringOrder) {
1006
+ const blips = quadrant.blips.filter((b) => b.ring === ringName);
1007
+ if (blips.length === 0) continue;
1008
+
1009
+ // Ring group container
1010
+ const ringGroup = document.createElement('div');
1011
+ ringGroup.style.cssText = `
1012
+ background: ${palette.surface};
1013
+ border-radius: 8px;
1014
+ padding: 10px;
1015
+ margin-bottom: 12px;
1016
+ `;
1017
+
1018
+ // Ring header
1019
+ const header = document.createElement('div');
1020
+ header.style.cssText = `
1021
+ font-size: 13px; font-weight: 700; color: ${palette.textMuted};
1022
+ margin-bottom: 8px;
1023
+ `;
1024
+ header.textContent = ringName;
1025
+ ringGroup.appendChild(header);
1026
+
1027
+ panel.appendChild(ringGroup);
1028
+
1029
+ for (const blip of blips) {
1030
+ const hasDesc = blip.description.length > 0;
1031
+
1032
+ const node = document.createElement('div');
1033
+ node.style.cssText = `
1034
+ background: ${fillColor}; border: 1.5px solid ${qColor};
1035
+ border-radius: 6px; margin-bottom: 6px;
1036
+ `;
1037
+
1038
+ // Title row
1039
+ const titleRow = document.createElement('div');
1040
+ titleRow.style.cssText = `
1041
+ display: flex; align-items: center; gap: 8px;
1042
+ padding: 6px 10px; min-height: 28px;
1043
+ `;
1044
+
1045
+ // Mini SVG blip indicator
1046
+ const indicatorSvg = document.createElementNS(
1047
+ 'http://www.w3.org/2000/svg',
1048
+ 'svg'
1049
+ );
1050
+ indicatorSvg.setAttribute('width', '26');
1051
+ indicatorSvg.setAttribute('height', '26');
1052
+ indicatorSvg.style.flexShrink = '0';
1053
+ const indicatorG = d3Selection
1054
+ .select(indicatorSvg)
1055
+ .append('g') as d3Selection.Selection<
1056
+ SVGGElement,
1057
+ unknown,
1058
+ null,
1059
+ undefined
1060
+ >;
1061
+ renderTrendIndicator(
1062
+ indicatorG,
1063
+ blip.trend,
1064
+ qColor,
1065
+ 13,
1066
+ 13,
1067
+ 10,
1068
+ -Math.PI / 2
1069
+ );
1070
+ d3Selection
1071
+ .select(indicatorSvg)
1072
+ .append('text')
1073
+ .attr('x', 13)
1074
+ .attr('y', 16)
1075
+ .attr('text-anchor', 'middle')
1076
+ .attr('fill', isDark ? '#000' : '#fff')
1077
+ .attr('font-family', FONT_FAMILY)
1078
+ .attr('font-size', 9)
1079
+ .attr('font-weight', 'bold')
1080
+ .text(blip.globalNumber);
1081
+ titleRow.appendChild(indicatorSvg);
1082
+
1083
+ // Name
1084
+ const name = document.createElement('span');
1085
+ name.textContent = blip.name;
1086
+ name.style.cssText = `
1087
+ flex: 1; font-size: 12px; font-weight: 600;
1088
+ color: ${palette.text};
1089
+ `;
1090
+ titleRow.appendChild(name);
1091
+
1092
+ node.appendChild(titleRow);
1093
+
1094
+ // Description (always expanded for export)
1095
+ if (hasDesc) {
1096
+ const sepDiv = document.createElement('div');
1097
+ sepDiv.style.cssText = `border-top: 1px solid ${qColor}; opacity: 0.3;`;
1098
+ node.appendChild(sepDiv);
1099
+
1100
+ const descDiv = document.createElement('div');
1101
+ descDiv.style.cssText = `
1102
+ padding: 6px 10px 8px; font-size: 11px; line-height: 1.6;
1103
+ color: ${palette.textMuted};
1104
+ `;
1105
+ descDiv.innerHTML = renderDescriptionHtml(blip.description, palette);
1106
+ node.appendChild(descDiv);
1107
+ }
1108
+
1109
+ ringGroup.appendChild(node);
1110
+ }
1111
+ }
1112
+ }