@diagrammo/dgmo 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -256,7 +256,7 @@ export function renderC4Context(
256
256
 
257
257
  const scaledW = diagramW * scale;
258
258
  const offsetX = (width - scaledW) / 2;
259
- const offsetY = titleHeight + DIAGRAM_PADDING;
259
+ const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
260
260
 
261
261
  const svg = d3Selection
262
262
  .select(container)
@@ -592,12 +592,12 @@ export function renderC4Context(
592
592
 
593
593
  // ── Legend ──
594
594
  if (hasLegend) {
595
- // App mode: fixed overlay at SVG bottom so it's always readable regardless of scale.
595
+ // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
596
596
  // Export mode: render inside scaled contentG at layout coordinates.
597
597
  const legendParent = fixedLegend
598
598
  ? svg.append('g')
599
599
  .attr('class', 'c4-legend-fixed')
600
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`)
600
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
601
601
  : contentG.append('g').attr('class', 'c4-legend');
602
602
  if (activeTagGroup) {
603
603
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
@@ -1298,7 +1298,7 @@ export function renderC4Containers(
1298
1298
 
1299
1299
  const scaledW = diagramW * scale;
1300
1300
  const offsetX = (width - scaledW) / 2;
1301
- const offsetY = titleHeight + DIAGRAM_PADDING;
1301
+ const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
1302
1302
 
1303
1303
  const svg = d3Selection
1304
1304
  .select(container)
@@ -1707,12 +1707,12 @@ export function renderC4Containers(
1707
1707
 
1708
1708
  // ── Legend ──
1709
1709
  if (hasLegend) {
1710
- // App mode: fixed overlay at SVG bottom so it's always readable regardless of scale.
1710
+ // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
1711
1711
  // Export mode: render inside scaled contentG at layout coordinates.
1712
1712
  const legendParent = fixedLegend
1713
1713
  ? svg.append('g')
1714
1714
  .attr('class', 'c4-legend-fixed')
1715
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`)
1715
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
1716
1716
  : contentG.append('g').attr('class', 'c4-legend');
1717
1717
  if (activeTagGroup) {
1718
1718
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
@@ -6,6 +6,18 @@ import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import { runInExportContainer, extractExportSvg } from '../utils/export-container';
9
+ import {
10
+ LEGEND_HEIGHT,
11
+ LEGEND_PILL_PAD,
12
+ LEGEND_PILL_FONT_SIZE,
13
+ LEGEND_PILL_FONT_W,
14
+ LEGEND_CAPSULE_PAD,
15
+ LEGEND_DOT_R,
16
+ LEGEND_ENTRY_FONT_SIZE,
17
+ LEGEND_ENTRY_FONT_W,
18
+ LEGEND_ENTRY_DOT_GAP,
19
+ LEGEND_ENTRY_TRAIL,
20
+ } from '../utils/legend-constants';
9
21
  import type { PaletteColors } from '../palettes';
10
22
  import { mix } from '../palettes/color-utils';
11
23
  import type { ParsedClassDiagram, ClassModifier, RelationshipType } from './types';
@@ -51,6 +63,49 @@ function nodeStroke(palette: PaletteColors, modifier: ClassModifier | undefined,
51
63
  return nodeColor ?? modifierColor(modifier, palette, colorOff);
52
64
  }
53
65
 
66
+ // ============================================================
67
+ // Legend helpers
68
+ // ============================================================
69
+
70
+ interface ClassLegendEntry {
71
+ label: string;
72
+ colorKey: keyof PaletteColors['colors'];
73
+ }
74
+
75
+ const CLASS_TYPE_MAP: Record<string, ClassLegendEntry> = {
76
+ class: { label: 'Class', colorKey: 'teal' },
77
+ abstract: { label: 'Abstract', colorKey: 'purple' },
78
+ interface: { label: 'Interface', colorKey: 'blue' },
79
+ enum: { label: 'Enum', colorKey: 'yellow' },
80
+ };
81
+
82
+ const CLASS_TYPE_ORDER = ['class', 'abstract', 'interface', 'enum'];
83
+
84
+ function collectClassTypes(parsed: ParsedClassDiagram): ClassLegendEntry[] {
85
+ if (parsed.options?.color === 'off') return [];
86
+
87
+ const present = new Set<string>();
88
+ for (const c of parsed.classes) {
89
+ if (c.color) continue; // explicit color override — skip
90
+ present.add(c.modifier ?? 'class');
91
+ }
92
+ return CLASS_TYPE_ORDER.filter((k) => present.has(k)).map((k) => CLASS_TYPE_MAP[k]);
93
+ }
94
+
95
+ const LEGEND_GROUP_NAME = 'Type';
96
+
97
+ function legendEntriesWidth(entries: ClassLegendEntry[]): number {
98
+ let w = 0;
99
+ for (const e of entries) {
100
+ w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
101
+ }
102
+ return w;
103
+ }
104
+
105
+ function classTypeKey(modifier: ClassModifier | undefined): string {
106
+ return modifier ?? 'class';
107
+ }
108
+
54
109
  // ============================================================
55
110
  // Visibility symbols
56
111
  // ============================================================
@@ -107,7 +162,8 @@ export function renderClassDiagram(
107
162
  palette: PaletteColors,
108
163
  isDark: boolean,
109
164
  onClickItem?: (lineNumber: number) => void,
110
- exportDims?: { width?: number; height?: number }
165
+ exportDims?: { width?: number; height?: number },
166
+ legendActive?: boolean | null
111
167
  ): void {
112
168
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
113
169
 
@@ -115,17 +171,22 @@ export function renderClassDiagram(
115
171
  const height = exportDims?.height ?? container.clientHeight;
116
172
  if (width <= 0 || height <= 0) return;
117
173
 
174
+ const legendEntries = collectClassTypes(parsed);
175
+ const hasLegend = legendEntries.length > 1; // only show when multiple types present
176
+
118
177
  const titleHeight = parsed.title ? 40 : 0;
178
+ const LEGEND_FIXED_GAP = 8;
179
+ const legendReserve = hasLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
119
180
  const diagramW = layout.width;
120
181
  const diagramH = layout.height;
121
- const availH = height - titleHeight;
182
+ const availH = height - titleHeight - legendReserve;
122
183
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
123
184
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
124
185
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
125
186
 
126
187
  const scaledW = diagramW * scale;
127
188
  const offsetX = (width - scaledW) / 2;
128
- const offsetY = titleHeight + DIAGRAM_PADDING;
189
+ const offsetY = titleHeight + legendReserve + DIAGRAM_PADDING;
129
190
 
130
191
  const svg = d3Selection
131
192
  .select(container)
@@ -244,6 +305,114 @@ export function renderClassDiagram(
244
305
  }
245
306
  }
246
307
 
308
+ // ── Legend ──
309
+ // legendActive: true = expanded (default), false = collapsed pill only
310
+ const isLegendExpanded = legendActive !== false;
311
+ if (hasLegend) {
312
+ const groupBg = isDark
313
+ ? mix(palette.surface, palette.bg, 50)
314
+ : mix(palette.surface, palette.bg, 30);
315
+
316
+ const pillWidth = LEGEND_GROUP_NAME.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
317
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
318
+ const entriesW = legendEntriesWidth(legendEntries);
319
+
320
+ const totalW = isLegendExpanded
321
+ ? LEGEND_CAPSULE_PAD * 2 + pillWidth + LEGEND_ENTRY_TRAIL + entriesW
322
+ : pillWidth;
323
+
324
+ const legendX = (width - totalW) / 2;
325
+ const legendY = titleHeight;
326
+
327
+ const legendG = svg
328
+ .append('g')
329
+ .attr('class', 'cd-legend')
330
+ .attr('data-legend-group', 'type')
331
+ .attr('transform', `translate(${legendX}, ${legendY})`)
332
+ .style('cursor', 'pointer');
333
+
334
+ if (isLegendExpanded) {
335
+ // Outer capsule
336
+ legendG.append('rect')
337
+ .attr('width', totalW)
338
+ .attr('height', LEGEND_HEIGHT)
339
+ .attr('rx', LEGEND_HEIGHT / 2)
340
+ .attr('fill', groupBg);
341
+
342
+ // Inner pill
343
+ legendG.append('rect')
344
+ .attr('x', LEGEND_CAPSULE_PAD)
345
+ .attr('y', LEGEND_CAPSULE_PAD)
346
+ .attr('width', pillWidth)
347
+ .attr('height', pillH)
348
+ .attr('rx', pillH / 2)
349
+ .attr('fill', palette.bg);
350
+
351
+ legendG.append('rect')
352
+ .attr('x', LEGEND_CAPSULE_PAD)
353
+ .attr('y', LEGEND_CAPSULE_PAD)
354
+ .attr('width', pillWidth)
355
+ .attr('height', pillH)
356
+ .attr('rx', pillH / 2)
357
+ .attr('fill', 'none')
358
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
359
+ .attr('stroke-width', 0.75);
360
+
361
+ legendG.append('text')
362
+ .attr('x', LEGEND_CAPSULE_PAD + pillWidth / 2)
363
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
364
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
365
+ .attr('font-weight', '500')
366
+ .attr('fill', palette.text)
367
+ .attr('text-anchor', 'middle')
368
+ .attr('font-family', FONT_FAMILY)
369
+ .text(LEGEND_GROUP_NAME);
370
+
371
+ // Entries
372
+ let entryX = LEGEND_CAPSULE_PAD + pillWidth + LEGEND_ENTRY_TRAIL;
373
+ for (const entry of legendEntries) {
374
+ const color = palette.colors[entry.colorKey];
375
+ const typeKey = CLASS_TYPE_ORDER.find((k) => CLASS_TYPE_MAP[k] === entry)!;
376
+
377
+ const entryG = legendG.append('g')
378
+ .attr('data-legend-entry', typeKey);
379
+
380
+ entryG.append('circle')
381
+ .attr('cx', entryX + LEGEND_DOT_R)
382
+ .attr('cy', LEGEND_HEIGHT / 2)
383
+ .attr('r', LEGEND_DOT_R)
384
+ .attr('fill', color);
385
+
386
+ entryG.append('text')
387
+ .attr('x', entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
388
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
389
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
390
+ .attr('fill', palette.textMuted)
391
+ .attr('font-family', FONT_FAMILY)
392
+ .text(entry.label);
393
+
394
+ entryX += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
395
+ }
396
+ } else {
397
+ // Collapsed: single muted pill
398
+ legendG.append('rect')
399
+ .attr('width', pillWidth)
400
+ .attr('height', LEGEND_HEIGHT)
401
+ .attr('rx', LEGEND_HEIGHT / 2)
402
+ .attr('fill', groupBg);
403
+
404
+ legendG.append('text')
405
+ .attr('x', pillWidth / 2)
406
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
407
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
408
+ .attr('font-weight', '500')
409
+ .attr('fill', palette.textMuted)
410
+ .attr('text-anchor', 'middle')
411
+ .attr('font-family', FONT_FAMILY)
412
+ .text(LEGEND_GROUP_NAME);
413
+ }
414
+ }
415
+
247
416
  // ── Content group ──
248
417
  const contentG = svg
249
418
  .append('g')
@@ -320,7 +489,8 @@ export function renderClassDiagram(
320
489
  .attr('transform', `translate(${node.x}, ${node.y})`)
321
490
  .attr('class', 'cd-class')
322
491
  .attr('data-line-number', String(node.lineNumber))
323
- .attr('data-node-id', node.id);
492
+ .attr('data-node-id', node.id)
493
+ .attr('data-cd-type', classTypeKey(node.modifier));
324
494
 
325
495
  if (onClickItem) {
326
496
  nodeG.style('cursor', 'pointer').on('click', () => {
@@ -331,8 +501,11 @@ export function renderClassDiagram(
331
501
  const w = node.width;
332
502
  const h = node.height;
333
503
  const colorOff = parsed.options?.color === 'off';
334
- const fill = nodeFill(palette, isDark, node.modifier, node.color, colorOff);
335
- const stroke = nodeStroke(palette, node.modifier, node.color, colorOff);
504
+ // When legend is collapsed, use neutral color for nodes without explicit color
505
+ const neutralize = hasLegend && !isLegendExpanded && !node.color;
506
+ const effectiveColor = neutralize ? palette.primary : node.color;
507
+ const fill = nodeFill(palette, isDark, node.modifier, effectiveColor, colorOff);
508
+ const stroke = nodeStroke(palette, node.modifier, effectiveColor, colorOff);
336
509
 
337
510
  // Outer rectangle
338
511
  nodeG.append('rect')
@@ -504,8 +677,11 @@ export function renderClassDiagramForExport(
504
677
  const layout = layoutClassDiagram(parsed);
505
678
  const isDark = theme === 'dark';
506
679
 
680
+ const legendEntries = collectClassTypes(parsed);
681
+ const EXPORT_LEGEND_GAP = 8;
682
+ const legendReserve = legendEntries.length > 1 ? LEGEND_HEIGHT + EXPORT_LEGEND_GAP : 0;
507
683
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
508
- const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0);
684
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0) + legendReserve;
509
685
 
510
686
  return runInExportContainer(exportWidth, exportHeight, (container) => {
511
687
  renderClassDiagram(
package/src/cli.ts CHANGED
@@ -8,21 +8,13 @@ import { render } from './render';
8
8
  import { parseDgmo, getAllChartTypes } from './dgmo-router';
9
9
  import { parseDgmoChartType } from './dgmo-router';
10
10
  import { formatDgmoError } from './diagnostics';
11
- import { getPalette } from './palettes/registry';
11
+ import { getPalette, getAvailablePalettes } from './palettes';
12
12
  import { DEFAULT_FONT_NAME } from './fonts';
13
13
  import { encodeDiagramUrl } from './sharing';
14
14
  import { resolveOrgImports } from './org/resolver';
15
15
 
16
- const PALETTES = [
17
- 'nord',
18
- 'solarized',
19
- 'catppuccin',
20
- 'rose-pine',
21
- 'gruvbox',
22
- 'tokyo-night',
23
- 'one-dark',
24
- 'bold',
25
- ];
16
+ // Derived from the palette registry so new palettes are auto-included.
17
+ const PALETTES = getAvailablePalettes().map((p) => p.id);
26
18
 
27
19
  const THEMES = ['light', 'dark', 'transparent'] as const;
28
20
 
package/src/colors.ts CHANGED
@@ -41,9 +41,9 @@ export const colorNames: Record<string, string> = {
41
41
  };
42
42
 
43
43
  /**
44
- * Resolves a color name or hex code to a valid CSS color.
44
+ * Resolves a color name to a valid CSS color.
45
45
  * When a palette is provided, named colors resolve against its color map first.
46
- * Hex codes (e.g. "#ff0000") are passed through regardless of palette (FR8).
46
+ * Hex codes are NOT supported use named colors only.
47
47
  * Unknown names are returned as-is.
48
48
  */
49
49
  export function resolveColor(
@@ -51,7 +51,6 @@ export function resolveColor(
51
51
  palette?: { colors: Record<string, string> }
52
52
  ): string {
53
53
  const lower = color.toLowerCase();
54
- if (lower.startsWith('#')) return lower;
55
54
 
56
55
  if (palette) {
57
56
  const named = palette.colors[lower];
@@ -59,6 +58,7 @@ export function resolveColor(
59
58
  }
60
59
 
61
60
  if (colorNames[lower]) return colorNames[lower];
61
+ // Unknown color name — return as-is so callers can detect + warn
62
62
  return color;
63
63
  }
64
64
 
package/src/d3.ts CHANGED
@@ -3059,7 +3059,7 @@ export function renderTimeline(
3059
3059
  }
3060
3060
  }
3061
3061
 
3062
- // Reserve space for tag legend at the bottom of chart content
3062
+ // Reserve space for tag legend at the top of chart content (below title/headers)
3063
3063
  const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
3064
3064
 
3065
3065
  // ================================================================
@@ -3099,9 +3099,9 @@ export function renderTimeline(
3099
3099
  const scaleMargin = timelineScale ? 40 : 0;
3100
3100
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3101
3101
  const margin = {
3102
- top: 104 + markerMargin,
3102
+ top: 104 + markerMargin + tagLegendReserve,
3103
3103
  right: 40 + scaleMargin,
3104
- bottom: 40 + tagLegendReserve,
3104
+ bottom: 40,
3105
3105
  left: 60 + scaleMargin,
3106
3106
  };
3107
3107
  const innerWidth = width - margin.left - margin.right;
@@ -3254,12 +3254,14 @@ export function renderTimeline(
3254
3254
  const rectH = Math.max(y2 - y, 4);
3255
3255
 
3256
3256
  let fill: string = mix(evColor, bg, 30);
3257
+ let stroke: string = evColor;
3257
3258
  if (ev.uncertain) {
3258
3259
  const gradientId = `uncertain-vg-${ev.lineNumber}`;
3260
+ const strokeGradientId = `uncertain-vg-s-${ev.lineNumber}`;
3259
3261
  const defs =
3260
3262
  svg.select('defs').node() || svg.append('defs').node();
3261
- d3Selection
3262
- .select(defs as Element)
3263
+ const defsEl = d3Selection.select(defs as Element);
3264
+ defsEl
3263
3265
  .append('linearGradient')
3264
3266
  .attr('id', gradientId)
3265
3267
  .attr('x1', '0%')
@@ -3277,7 +3279,26 @@ export function renderTimeline(
3277
3279
  .attr('offset', (d) => d.offset)
3278
3280
  .attr('stop-color', mix(laneColor, bg, 30))
3279
3281
  .attr('stop-opacity', (d) => d.opacity);
3282
+ defsEl
3283
+ .append('linearGradient')
3284
+ .attr('id', strokeGradientId)
3285
+ .attr('x1', '0%')
3286
+ .attr('y1', '0%')
3287
+ .attr('x2', '0%')
3288
+ .attr('y2', '100%')
3289
+ .selectAll('stop')
3290
+ .data([
3291
+ { offset: '0%', opacity: 1 },
3292
+ { offset: '80%', opacity: 1 },
3293
+ { offset: '100%', opacity: 0 },
3294
+ ])
3295
+ .enter()
3296
+ .append('stop')
3297
+ .attr('offset', (d) => d.offset)
3298
+ .attr('stop-color', evColor)
3299
+ .attr('stop-opacity', (d) => d.opacity);
3280
3300
  fill = `url(#${gradientId})`;
3301
+ stroke = `url(#${strokeGradientId})`;
3281
3302
  }
3282
3303
 
3283
3304
  evG
@@ -3288,7 +3309,7 @@ export function renderTimeline(
3288
3309
  .attr('height', rectH)
3289
3310
  .attr('rx', 4)
3290
3311
  .attr('fill', fill)
3291
- .attr('stroke', evColor)
3312
+ .attr('stroke', stroke)
3292
3313
  .attr('stroke-width', 2);
3293
3314
  evG
3294
3315
  .append('text')
@@ -3323,9 +3344,9 @@ export function renderTimeline(
3323
3344
  const scaleMargin = timelineScale ? 40 : 0;
3324
3345
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3325
3346
  const margin = {
3326
- top: 104 + markerMargin,
3347
+ top: 104 + markerMargin + tagLegendReserve,
3327
3348
  right: 200,
3328
- bottom: 40 + tagLegendReserve,
3349
+ bottom: 40,
3329
3350
  left: 60 + scaleMargin,
3330
3351
  };
3331
3352
  const innerWidth = width - margin.left - margin.right;
@@ -3474,12 +3495,14 @@ export function renderTimeline(
3474
3495
  const rectH = Math.max(y2 - y, 4);
3475
3496
 
3476
3497
  let fill: string = mix(color, bg, 30);
3498
+ let stroke: string = color;
3477
3499
  if (ev.uncertain) {
3478
3500
  const gradientId = `uncertain-v-${ev.lineNumber}`;
3501
+ const strokeGradientId = `uncertain-v-s-${ev.lineNumber}`;
3479
3502
  const defs =
3480
3503
  svg.select('defs').node() || svg.append('defs').node();
3481
- d3Selection
3482
- .select(defs as Element)
3504
+ const defsEl = d3Selection.select(defs as Element);
3505
+ defsEl
3483
3506
  .append('linearGradient')
3484
3507
  .attr('id', gradientId)
3485
3508
  .attr('x1', '0%')
@@ -3497,7 +3520,26 @@ export function renderTimeline(
3497
3520
  .attr('offset', (d) => d.offset)
3498
3521
  .attr('stop-color', mix(color, bg, 30))
3499
3522
  .attr('stop-opacity', (d) => d.opacity);
3523
+ defsEl
3524
+ .append('linearGradient')
3525
+ .attr('id', strokeGradientId)
3526
+ .attr('x1', '0%')
3527
+ .attr('y1', '0%')
3528
+ .attr('x2', '0%')
3529
+ .attr('y2', '100%')
3530
+ .selectAll('stop')
3531
+ .data([
3532
+ { offset: '0%', opacity: 1 },
3533
+ { offset: '80%', opacity: 1 },
3534
+ { offset: '100%', opacity: 0 },
3535
+ ])
3536
+ .enter()
3537
+ .append('stop')
3538
+ .attr('offset', (d) => d.offset)
3539
+ .attr('stop-color', color)
3540
+ .attr('stop-opacity', (d) => d.opacity);
3500
3541
  fill = `url(#${gradientId})`;
3542
+ stroke = `url(#${strokeGradientId})`;
3501
3543
  }
3502
3544
 
3503
3545
  evG
@@ -3508,7 +3550,7 @@ export function renderTimeline(
3508
3550
  .attr('height', rectH)
3509
3551
  .attr('rx', 4)
3510
3552
  .attr('fill', fill)
3511
- .attr('stroke', color)
3553
+ .attr('stroke', stroke)
3512
3554
  .attr('stroke-width', 2);
3513
3555
  evG
3514
3556
  .append('text')
@@ -3606,9 +3648,9 @@ export function renderTimeline(
3606
3648
  // Group-sorted doesn't need legend space (group names shown on left)
3607
3649
  const baseTopMargin = title ? 50 : 20;
3608
3650
  const margin = {
3609
- top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin,
3651
+ top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
3610
3652
  right: 40,
3611
- bottom: 40 + scaleMargin + tagLegendReserve,
3653
+ bottom: 40 + scaleMargin,
3612
3654
  left: dynamicLeftMargin,
3613
3655
  };
3614
3656
  const innerWidth = width - margin.left - margin.right;
@@ -3785,12 +3827,14 @@ export function renderTimeline(
3785
3827
  const labelFitsInside = rectW >= estLabelWidth;
3786
3828
 
3787
3829
  let fill: string = mix(evColor, bg, 30);
3830
+ let stroke: string = evColor;
3788
3831
  if (ev.uncertain) {
3789
3832
  // Create gradient for uncertain end - fades last 20%
3790
3833
  const gradientId = `uncertain-${ev.lineNumber}`;
3834
+ const strokeGradientId = `uncertain-s-${ev.lineNumber}`;
3791
3835
  const defs = svg.select('defs').node() || svg.append('defs').node();
3792
- d3Selection
3793
- .select(defs as Element)
3836
+ const defsEl = d3Selection.select(defs as Element);
3837
+ defsEl
3794
3838
  .append('linearGradient')
3795
3839
  .attr('id', gradientId)
3796
3840
  .attr('x1', '0%')
@@ -3808,7 +3852,26 @@ export function renderTimeline(
3808
3852
  .attr('offset', (d) => d.offset)
3809
3853
  .attr('stop-color', mix(evColor, bg, 30))
3810
3854
  .attr('stop-opacity', (d) => d.opacity);
3855
+ defsEl
3856
+ .append('linearGradient')
3857
+ .attr('id', strokeGradientId)
3858
+ .attr('x1', '0%')
3859
+ .attr('y1', '0%')
3860
+ .attr('x2', '100%')
3861
+ .attr('y2', '0%')
3862
+ .selectAll('stop')
3863
+ .data([
3864
+ { offset: '0%', opacity: 1 },
3865
+ { offset: '80%', opacity: 1 },
3866
+ { offset: '100%', opacity: 0 },
3867
+ ])
3868
+ .enter()
3869
+ .append('stop')
3870
+ .attr('offset', (d) => d.offset)
3871
+ .attr('stop-color', evColor)
3872
+ .attr('stop-opacity', (d) => d.opacity);
3811
3873
  fill = `url(#${gradientId})`;
3874
+ stroke = `url(#${strokeGradientId})`;
3812
3875
  }
3813
3876
 
3814
3877
  evG
@@ -3819,7 +3882,7 @@ export function renderTimeline(
3819
3882
  .attr('height', BAR_H)
3820
3883
  .attr('rx', 4)
3821
3884
  .attr('fill', fill)
3822
- .attr('stroke', evColor)
3885
+ .attr('stroke', stroke)
3823
3886
  .attr('stroke-width', 2);
3824
3887
 
3825
3888
  if (labelFitsInside) {
@@ -3887,9 +3950,9 @@ export function renderTimeline(
3887
3950
  const scaleMargin = timelineScale ? 24 : 0;
3888
3951
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3889
3952
  const margin = {
3890
- top: 104 + (timelineScale ? 40 : 0) + markerMargin,
3953
+ top: 104 + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
3891
3954
  right: 40,
3892
- bottom: 40 + scaleMargin + tagLegendReserve,
3955
+ bottom: 40 + scaleMargin,
3893
3956
  left: 60,
3894
3957
  };
3895
3958
  const innerWidth = width - margin.left - margin.right;
@@ -4047,12 +4110,14 @@ export function renderTimeline(
4047
4110
  const labelFitsInside = rectW >= estLabelWidth;
4048
4111
 
4049
4112
  let fill: string = mix(color, bg, 30);
4113
+ let stroke: string = color;
4050
4114
  if (ev.uncertain) {
4051
4115
  // Create gradient for uncertain end - fades last 20%
4052
4116
  const gradientId = `uncertain-ts-${ev.lineNumber}`;
4117
+ const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;
4053
4118
  const defs = svg.select('defs').node() || svg.append('defs').node();
4054
- d3Selection
4055
- .select(defs as Element)
4119
+ const defsEl = d3Selection.select(defs as Element);
4120
+ defsEl
4056
4121
  .append('linearGradient')
4057
4122
  .attr('id', gradientId)
4058
4123
  .attr('x1', '0%')
@@ -4070,7 +4135,26 @@ export function renderTimeline(
4070
4135
  .attr('offset', (d) => d.offset)
4071
4136
  .attr('stop-color', mix(color, bg, 30))
4072
4137
  .attr('stop-opacity', (d) => d.opacity);
4138
+ defsEl
4139
+ .append('linearGradient')
4140
+ .attr('id', strokeGradientId)
4141
+ .attr('x1', '0%')
4142
+ .attr('y1', '0%')
4143
+ .attr('x2', '100%')
4144
+ .attr('y2', '0%')
4145
+ .selectAll('stop')
4146
+ .data([
4147
+ { offset: '0%', opacity: 1 },
4148
+ { offset: '80%', opacity: 1 },
4149
+ { offset: '100%', opacity: 0 },
4150
+ ])
4151
+ .enter()
4152
+ .append('stop')
4153
+ .attr('offset', (d) => d.offset)
4154
+ .attr('stop-color', color)
4155
+ .attr('stop-opacity', (d) => d.opacity);
4073
4156
  fill = `url(#${gradientId})`;
4157
+ stroke = `url(#${strokeGradientId})`;
4074
4158
  }
4075
4159
 
4076
4160
  evG
@@ -4081,7 +4165,7 @@ export function renderTimeline(
4081
4165
  .attr('height', BAR_H)
4082
4166
  .attr('rx', 4)
4083
4167
  .attr('fill', fill)
4084
- .attr('stroke', color)
4168
+ .attr('stroke', stroke)
4085
4169
  .attr('stroke-width', 2);
4086
4170
 
4087
4171
  if (labelFitsInside) {
@@ -4157,7 +4241,8 @@ export function renderTimeline(
4157
4241
  const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
4158
4242
  const mainG = mainSvg.select<SVGGElement>('g');
4159
4243
  if (!mainSvg.empty() && !mainG.empty()) {
4160
- const legendY = height - LG_HEIGHT - 4;
4244
+ // Position legend at top, below title
4245
+ const legendY = title ? 50 : 10;
4161
4246
 
4162
4247
  const groupBg = isDark
4163
4248
  ? mix(palette.surface, palette.bg, 50)
@@ -5914,7 +5999,7 @@ export async function renderForExport(
5914
5999
  const exportHeight = isLayout.height + PADDING * 2 + titleOffset;
5915
6000
  const container = createExportContainer(exportWidth, exportHeight);
5916
6001
 
5917
- renderInitiativeStatus(container, isParsed, isLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
6002
+ renderInitiativeStatus(container, isParsed, isLayout, effectivePalette, theme === 'dark', { exportDims: { width: exportWidth, height: exportHeight } });
5918
6003
  return finalizeSvgExport(container, theme, effectivePalette, options);
5919
6004
  }
5920
6005
 
@@ -6003,6 +6088,26 @@ export async function renderForExport(
6003
6088
  return finalizeSvgExport(container, theme, effectivePalette, options);
6004
6089
  }
6005
6090
 
6091
+ if (detectedType === 'gantt') {
6092
+ const { parseGantt } = await import('./gantt/parser');
6093
+ const { calculateSchedule } = await import('./gantt/calculator');
6094
+ const { renderGantt } = await import('./gantt/renderer');
6095
+
6096
+ const effectivePalette = await resolveExportPalette(theme, palette);
6097
+ const ganttParsed = parseGantt(content, effectivePalette);
6098
+ if (ganttParsed.error) return '';
6099
+
6100
+ const resolved = calculateSchedule(ganttParsed);
6101
+ if (resolved.error || resolved.tasks.length === 0) return '';
6102
+
6103
+ const EXPORT_W = 1200;
6104
+ const EXPORT_H = 800;
6105
+ const container = createExportContainer(EXPORT_W, EXPORT_H);
6106
+
6107
+ renderGantt(container, resolved, effectivePalette, theme === 'dark', undefined, { width: EXPORT_W, height: EXPORT_H });
6108
+ return finalizeSvgExport(container, theme, effectivePalette, options);
6109
+ }
6110
+
6006
6111
  if (detectedType === 'state') {
6007
6112
  const { parseState } = await import('./graph/state-parser');
6008
6113
  const { layoutGraph } = await import('./graph/layout');