@spectratools/graphic-designer-cli 0.3.2 → 0.6.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/dist/cli.js CHANGED
@@ -1,6 +1,153 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/compare.ts
13
+ var compare_exports = {};
14
+ __export(compare_exports, {
15
+ compareImages: () => compareImages
16
+ });
17
+ import sharp from "sharp";
18
+ function clampUnit(value) {
19
+ if (value < 0) {
20
+ return 0;
21
+ }
22
+ if (value > 1) {
23
+ return 1;
24
+ }
25
+ return value;
26
+ }
27
+ function toRegionLabel(row, column) {
28
+ const letter = String.fromCharCode(65 + row);
29
+ return `${letter}${column + 1}`;
30
+ }
31
+ function validateGrid(grid) {
32
+ if (!Number.isInteger(grid) || grid <= 0) {
33
+ throw new Error(`Invalid grid value "${grid}". Expected a positive integer.`);
34
+ }
35
+ if (grid > 26) {
36
+ throw new Error(`Invalid grid value "${grid}". Maximum supported grid is 26.`);
37
+ }
38
+ return grid;
39
+ }
40
+ function validateThreshold(threshold) {
41
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
42
+ throw new Error(`Invalid threshold value "${threshold}". Expected a number between 0 and 1.`);
43
+ }
44
+ return threshold;
45
+ }
46
+ async function readDimensions(path) {
47
+ const metadata = await sharp(path).metadata();
48
+ if (!metadata.width || !metadata.height) {
49
+ throw new Error(`Unable to read image dimensions for "${path}".`);
50
+ }
51
+ return {
52
+ width: metadata.width,
53
+ height: metadata.height
54
+ };
55
+ }
56
+ async function normalizeToRaw(path, width, height) {
57
+ const normalized = await sharp(path).rotate().resize(width, height, {
58
+ fit: "contain",
59
+ position: "centre",
60
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
61
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
62
+ return {
63
+ data: normalized.data,
64
+ width: normalized.info.width,
65
+ height: normalized.info.height
66
+ };
67
+ }
68
+ function scorePixelDifference(a, b, offset) {
69
+ const redDiff = Math.abs(a.data[offset] - b.data[offset]);
70
+ const greenDiff = Math.abs(a.data[offset + 1] - b.data[offset + 1]);
71
+ const blueDiff = Math.abs(a.data[offset + 2] - b.data[offset + 2]);
72
+ const alphaDiff = Math.abs(a.data[offset + 3] - b.data[offset + 3]);
73
+ const rgbDelta = (redDiff + greenDiff + blueDiff) / (3 * 255);
74
+ const alphaDelta = alphaDiff / 255;
75
+ return rgbDelta * 0.75 + alphaDelta * 0.25;
76
+ }
77
+ async function compareImages(target, rendered, options = {}) {
78
+ const grid = validateGrid(options.grid ?? DEFAULT_GRID);
79
+ const threshold = validateThreshold(options.threshold ?? DEFAULT_THRESHOLD);
80
+ const closeThreshold = clampUnit(threshold - (options.closeMargin ?? DEFAULT_CLOSE_MARGIN));
81
+ const targetDimensions = await readDimensions(target);
82
+ const renderedDimensions = await readDimensions(rendered);
83
+ const normalizedWidth = Math.max(targetDimensions.width, renderedDimensions.width);
84
+ const normalizedHeight = Math.max(targetDimensions.height, renderedDimensions.height);
85
+ const [targetImage, renderedImage] = await Promise.all([
86
+ normalizeToRaw(target, normalizedWidth, normalizedHeight),
87
+ normalizeToRaw(rendered, normalizedWidth, normalizedHeight)
88
+ ]);
89
+ const regionDiffSums = new Array(grid * grid).fill(0);
90
+ const regionCounts = new Array(grid * grid).fill(0);
91
+ let totalDiff = 0;
92
+ for (let y = 0; y < normalizedHeight; y += 1) {
93
+ const row = Math.min(Math.floor(y * grid / normalizedHeight), grid - 1);
94
+ for (let x = 0; x < normalizedWidth; x += 1) {
95
+ const column = Math.min(Math.floor(x * grid / normalizedWidth), grid - 1);
96
+ const regionIndex = row * grid + column;
97
+ const offset = (y * normalizedWidth + x) * 4;
98
+ const diff = scorePixelDifference(targetImage, renderedImage, offset);
99
+ totalDiff += diff;
100
+ regionDiffSums[regionIndex] += diff;
101
+ regionCounts[regionIndex] += 1;
102
+ }
103
+ }
104
+ const pixelCount = normalizedWidth * normalizedHeight;
105
+ const similarity = clampUnit(1 - totalDiff / pixelCount);
106
+ const regions = [];
107
+ for (let row = 0; row < grid; row += 1) {
108
+ for (let column = 0; column < grid; column += 1) {
109
+ const regionIndex = row * grid + column;
110
+ const regionCount = regionCounts[regionIndex];
111
+ const regionSimilarity = regionCount > 0 ? clampUnit(1 - regionDiffSums[regionIndex] / regionCount) : 1;
112
+ regions.push({
113
+ label: toRegionLabel(row, column),
114
+ row,
115
+ column,
116
+ similarity: regionSimilarity
117
+ });
118
+ }
119
+ }
120
+ const verdict = similarity >= threshold ? "match" : similarity >= closeThreshold ? "close" : "mismatch";
121
+ return {
122
+ targetPath: target,
123
+ renderedPath: rendered,
124
+ targetDimensions,
125
+ renderedDimensions,
126
+ normalizedDimensions: {
127
+ width: normalizedWidth,
128
+ height: normalizedHeight
129
+ },
130
+ dimensionMismatch: targetDimensions.width !== renderedDimensions.width || targetDimensions.height !== renderedDimensions.height,
131
+ grid,
132
+ threshold,
133
+ closeThreshold,
134
+ similarity,
135
+ verdict,
136
+ regions
137
+ };
138
+ }
139
+ var DEFAULT_GRID, DEFAULT_THRESHOLD, DEFAULT_CLOSE_MARGIN;
140
+ var init_compare = __esm({
141
+ "src/compare.ts"() {
142
+ "use strict";
143
+ DEFAULT_GRID = 3;
144
+ DEFAULT_THRESHOLD = 0.8;
145
+ DEFAULT_CLOSE_MARGIN = 0.1;
146
+ }
147
+ });
2
148
 
3
149
  // src/cli.ts
150
+ init_compare();
4
151
  import { readFileSync, realpathSync } from "fs";
5
152
  import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
6
153
  import { basename as basename4, dirname as dirname3, resolve as resolve4 } from "path";
@@ -198,7 +345,7 @@ async function publishToGitHub(options) {
198
345
  // src/qa.ts
199
346
  import { readFile as readFile3 } from "fs/promises";
200
347
  import { resolve } from "path";
201
- import sharp from "sharp";
348
+ import sharp2 from "sharp";
202
349
 
203
350
  // src/code-style.ts
204
351
  var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
@@ -259,7 +406,110 @@ import { z as z2 } from "zod";
259
406
 
260
407
  // src/themes/builtin.ts
261
408
  import { z } from "zod";
262
- var colorHexSchema = z.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
409
+
410
+ // src/utils/color.ts
411
+ function parseChannel(hex, offset) {
412
+ return Number.parseInt(hex.slice(offset, offset + 2), 16);
413
+ }
414
+ function parseHexColor(hexColor) {
415
+ const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
416
+ if (normalized.length !== 6 && normalized.length !== 8) {
417
+ throw new Error(`Unsupported color format: ${hexColor}`);
418
+ }
419
+ return {
420
+ r: parseChannel(normalized, 0),
421
+ g: parseChannel(normalized, 2),
422
+ b: parseChannel(normalized, 4)
423
+ };
424
+ }
425
+ var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
426
+ var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
427
+ function toHex(n) {
428
+ return n.toString(16).padStart(2, "0");
429
+ }
430
+ function parseRgbaToHex(color) {
431
+ const match = rgbaRegex.exec(color);
432
+ if (!match) {
433
+ throw new Error(`Invalid rgb/rgba color: ${color}`);
434
+ }
435
+ const r = Number.parseInt(match[1], 10);
436
+ const g = Number.parseInt(match[2], 10);
437
+ const b = Number.parseInt(match[3], 10);
438
+ if (r > 255 || g > 255 || b > 255) {
439
+ throw new Error(`RGB channel values must be 0-255, got: ${color}`);
440
+ }
441
+ if (match[4] !== void 0) {
442
+ const a = Number.parseFloat(match[4]);
443
+ if (a < 0 || a > 1) {
444
+ throw new Error(`Alpha value must be 0-1, got: ${a}`);
445
+ }
446
+ const alphaByte = Math.round(a * 255);
447
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
448
+ }
449
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
450
+ }
451
+ function isRgbaColor(color) {
452
+ return rgbaRegex.test(color);
453
+ }
454
+ function isHexColor(color) {
455
+ return hexColorRegex.test(color);
456
+ }
457
+ function normalizeColor(color) {
458
+ if (isHexColor(color)) {
459
+ return color;
460
+ }
461
+ if (isRgbaColor(color)) {
462
+ return parseRgbaToHex(color);
463
+ }
464
+ throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
465
+ }
466
+ function srgbToLinear(channel) {
467
+ const normalized = channel / 255;
468
+ if (normalized <= 0.03928) {
469
+ return normalized / 12.92;
470
+ }
471
+ return ((normalized + 0.055) / 1.055) ** 2.4;
472
+ }
473
+ function relativeLuminance(hexColor) {
474
+ const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
475
+ const rgb = parseHexColor(normalized);
476
+ const r = srgbToLinear(rgb.r);
477
+ const g = srgbToLinear(rgb.g);
478
+ const b = srgbToLinear(rgb.b);
479
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
480
+ }
481
+ function contrastRatio(foreground, background) {
482
+ const fg = relativeLuminance(foreground);
483
+ const bg = relativeLuminance(background);
484
+ const lighter = Math.max(fg, bg);
485
+ const darker = Math.min(fg, bg);
486
+ return (lighter + 0.05) / (darker + 0.05);
487
+ }
488
+ function withAlpha(hexColor, opacity) {
489
+ const rgb = parseHexColor(hexColor);
490
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
491
+ }
492
+ function blendColorWithOpacity(foreground, background, opacity) {
493
+ const fg = parseHexColor(foreground);
494
+ const bg = parseHexColor(background);
495
+ const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
496
+ const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
497
+ const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
498
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
499
+ }
500
+
501
+ // src/themes/builtin.ts
502
+ var colorHexSchema = z.string().refine(
503
+ (v) => {
504
+ try {
505
+ normalizeColor(v);
506
+ return true;
507
+ } catch {
508
+ return false;
509
+ }
510
+ },
511
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
512
+ ).transform((v) => normalizeColor(v));
263
513
  var fontFamilySchema = z.string().min(1).max(120);
264
514
  var codeThemeSchema = z.object({
265
515
  background: colorHexSchema,
@@ -480,7 +730,17 @@ function resolveTheme(theme) {
480
730
  }
481
731
 
482
732
  // src/spec.schema.ts
483
- var colorHexSchema2 = z2.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
733
+ var colorHexSchema2 = z2.string().refine(
734
+ (v) => {
735
+ try {
736
+ normalizeColor(v);
737
+ return true;
738
+ } catch {
739
+ return false;
740
+ }
741
+ },
742
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
743
+ ).transform((v) => normalizeColor(v));
484
744
  var gradientStopSchema = z2.object({
485
745
  offset: z2.number().min(0).max(1),
486
746
  color: colorHexSchema2
@@ -664,13 +924,32 @@ var cardElementSchema = z2.object({
664
924
  tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
665
925
  icon: z2.string().min(1).max(64).optional()
666
926
  }).strict();
927
+ var flowNodeShadowSchema = z2.object({
928
+ color: colorHexSchema2.optional(),
929
+ blur: z2.number().min(0).max(64).default(8),
930
+ offsetX: z2.number().min(-32).max(32).default(0),
931
+ offsetY: z2.number().min(-32).max(32).default(0),
932
+ opacity: z2.number().min(0).max(1).default(0.3)
933
+ }).strict();
667
934
  var flowNodeElementSchema = z2.object({
668
935
  type: z2.literal("flow-node"),
669
936
  id: z2.string().min(1).max(120),
670
- shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
937
+ shape: z2.enum([
938
+ "box",
939
+ "rounded-box",
940
+ "diamond",
941
+ "circle",
942
+ "pill",
943
+ "cylinder",
944
+ "parallelogram",
945
+ "hexagon"
946
+ ]).default("rounded-box"),
671
947
  label: z2.string().min(1).max(200),
672
948
  sublabel: z2.string().min(1).max(300).optional(),
673
949
  sublabelColor: colorHexSchema2.optional(),
950
+ sublabel2: z2.string().min(1).max(300).optional(),
951
+ sublabel2Color: colorHexSchema2.optional(),
952
+ sublabel2FontSize: z2.number().min(8).max(32).optional(),
674
953
  labelColor: colorHexSchema2.optional(),
675
954
  labelFontSize: z2.number().min(10).max(48).optional(),
676
955
  color: colorHexSchema2.optional(),
@@ -679,20 +958,30 @@ var flowNodeElementSchema = z2.object({
679
958
  cornerRadius: z2.number().min(0).max(64).optional(),
680
959
  width: z2.number().int().min(40).max(800).optional(),
681
960
  height: z2.number().int().min(30).max(600).optional(),
682
- opacity: z2.number().min(0).max(1).default(1)
961
+ fillOpacity: z2.number().min(0).max(1).default(1),
962
+ opacity: z2.number().min(0).max(1).default(1),
963
+ badgeText: z2.string().min(1).max(32).optional(),
964
+ badgeColor: colorHexSchema2.optional(),
965
+ badgeBackground: colorHexSchema2.optional(),
966
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
967
+ shadow: flowNodeShadowSchema.optional()
683
968
  }).strict();
684
969
  var connectionElementSchema = z2.object({
685
970
  type: z2.literal("connection"),
686
971
  from: z2.string().min(1).max(120),
687
972
  to: z2.string().min(1).max(120),
688
973
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
974
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
689
975
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
690
976
  label: z2.string().min(1).max(200).optional(),
691
977
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
692
978
  color: colorHexSchema2.optional(),
693
- width: z2.number().min(0.5).max(8).optional(),
979
+ width: z2.number().min(0.5).max(10).optional(),
980
+ strokeWidth: z2.number().min(0.5).max(10).default(2),
694
981
  arrowSize: z2.number().min(4).max(32).optional(),
695
- opacity: z2.number().min(0).max(1).default(1)
982
+ opacity: z2.number().min(0).max(1).default(1),
983
+ routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
984
+ tension: z2.number().min(0.1).max(0.8).default(0.35)
696
985
  }).strict();
697
986
  var codeBlockStyleSchema = z2.object({
698
987
  paddingVertical: z2.number().min(0).max(128).default(56),
@@ -761,6 +1050,10 @@ var elementSchema = z2.discriminatedUnion("type", [
761
1050
  shapeElementSchema,
762
1051
  imageElementSchema
763
1052
  ]);
1053
+ var diagramCenterSchema = z2.object({
1054
+ x: z2.number(),
1055
+ y: z2.number()
1056
+ }).strict();
764
1057
  var autoLayoutConfigSchema = z2.object({
765
1058
  mode: z2.literal("auto"),
766
1059
  algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
@@ -768,7 +1061,17 @@ var autoLayoutConfigSchema = z2.object({
768
1061
  nodeSpacing: z2.number().int().min(0).max(512).default(80),
769
1062
  rankSpacing: z2.number().int().min(0).max(512).default(120),
770
1063
  edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
771
- aspectRatio: z2.number().min(0.5).max(3).optional()
1064
+ aspectRatio: z2.number().min(0.5).max(3).optional(),
1065
+ /** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
1066
+ radialRoot: z2.string().min(1).max(120).optional(),
1067
+ /** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
1068
+ radialRadius: z2.number().positive().optional(),
1069
+ /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
1070
+ radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
1071
+ /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
1072
+ radialSortBy: z2.enum(["id", "connections"]).optional(),
1073
+ /** Explicit center used by curve/arc connection routing. */
1074
+ diagramCenter: diagramCenterSchema.optional()
772
1075
  }).strict();
773
1076
  var gridLayoutConfigSchema = z2.object({
774
1077
  mode: z2.literal("grid"),
@@ -776,13 +1079,17 @@ var gridLayoutConfigSchema = z2.object({
776
1079
  gap: z2.number().int().min(0).max(256).default(24),
777
1080
  cardMinHeight: z2.number().int().min(32).max(4096).optional(),
778
1081
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
779
- equalHeight: z2.boolean().default(false)
1082
+ equalHeight: z2.boolean().default(false),
1083
+ /** Explicit center used by curve/arc connection routing. */
1084
+ diagramCenter: diagramCenterSchema.optional()
780
1085
  }).strict();
781
1086
  var stackLayoutConfigSchema = z2.object({
782
1087
  mode: z2.literal("stack"),
783
1088
  direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
784
1089
  gap: z2.number().int().min(0).max(256).default(24),
785
- alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
1090
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
1091
+ /** Explicit center used by curve/arc connection routing. */
1092
+ diagramCenter: diagramCenterSchema.optional()
786
1093
  }).strict();
787
1094
  var manualPositionSchema = z2.object({
788
1095
  x: z2.number().int(),
@@ -792,7 +1099,9 @@ var manualPositionSchema = z2.object({
792
1099
  }).strict();
793
1100
  var manualLayoutConfigSchema = z2.object({
794
1101
  mode: z2.literal("manual"),
795
- positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
1102
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
1103
+ /** Explicit center used by curve/arc connection routing. */
1104
+ diagramCenter: diagramCenterSchema.optional()
796
1105
  }).strict();
797
1106
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
798
1107
  autoLayoutConfigSchema,
@@ -844,6 +1153,31 @@ var canvasSchema = z2.object({
844
1153
  padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
845
1154
  }).strict();
846
1155
  var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
1156
+ var diagramPositionSchema = z2.object({
1157
+ x: z2.number(),
1158
+ y: z2.number(),
1159
+ width: z2.number().positive(),
1160
+ height: z2.number().positive()
1161
+ }).strict();
1162
+ var diagramElementSchema = z2.discriminatedUnion("type", [
1163
+ flowNodeElementSchema,
1164
+ connectionElementSchema
1165
+ ]);
1166
+ var diagramLayoutSchema = z2.object({
1167
+ mode: z2.enum(["manual", "auto"]).default("manual"),
1168
+ positions: z2.record(z2.string(), diagramPositionSchema).optional(),
1169
+ diagramCenter: diagramCenterSchema.optional()
1170
+ }).strict();
1171
+ var diagramSpecSchema = z2.object({
1172
+ version: z2.literal(1),
1173
+ canvas: z2.object({
1174
+ width: z2.number().int().min(320).max(4096).default(1200),
1175
+ height: z2.number().int().min(180).max(4096).default(675)
1176
+ }).default({ width: 1200, height: 675 }),
1177
+ theme: themeSchema.optional(),
1178
+ elements: z2.array(diagramElementSchema).min(1),
1179
+ layout: diagramLayoutSchema.default({ mode: "manual" })
1180
+ }).strict();
847
1181
  var designSpecSchema = z2.object({
848
1182
  version: z2.literal(2).default(2),
849
1183
  canvas: canvasSchema.default(defaultCanvas),
@@ -872,43 +1206,6 @@ function parseDesignSpec(input) {
872
1206
  return designSpecSchema.parse(input);
873
1207
  }
874
1208
 
875
- // src/utils/color.ts
876
- function parseChannel(hex, offset) {
877
- return Number.parseInt(hex.slice(offset, offset + 2), 16);
878
- }
879
- function parseHexColor(hexColor) {
880
- const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
881
- if (normalized.length !== 6 && normalized.length !== 8) {
882
- throw new Error(`Unsupported color format: ${hexColor}`);
883
- }
884
- return {
885
- r: parseChannel(normalized, 0),
886
- g: parseChannel(normalized, 2),
887
- b: parseChannel(normalized, 4)
888
- };
889
- }
890
- function srgbToLinear(channel) {
891
- const normalized = channel / 255;
892
- if (normalized <= 0.03928) {
893
- return normalized / 12.92;
894
- }
895
- return ((normalized + 0.055) / 1.055) ** 2.4;
896
- }
897
- function relativeLuminance(hexColor) {
898
- const rgb = parseHexColor(hexColor);
899
- const r = srgbToLinear(rgb.r);
900
- const g = srgbToLinear(rgb.g);
901
- const b = srgbToLinear(rgb.b);
902
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
903
- }
904
- function contrastRatio(foreground, background) {
905
- const fg = relativeLuminance(foreground);
906
- const bg = relativeLuminance(background);
907
- const lighter = Math.max(fg, bg);
908
- const darker = Math.min(fg, bg);
909
- return (lighter + 0.05) / (darker + 0.05);
910
- }
911
-
912
1209
  // src/qa.ts
913
1210
  function rectWithin(outer, inner) {
914
1211
  return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
@@ -954,7 +1251,7 @@ async function runQa(options) {
954
1251
  const imagePath = resolve(options.imagePath);
955
1252
  const expectedSafeFrame = deriveSafeFrame(spec);
956
1253
  const expectedCanvas = canvasRect(spec);
957
- const imageMetadata = await sharp(imagePath).metadata();
1254
+ const imageMetadata = await sharp2(imagePath).metadata();
958
1255
  const issues = [];
959
1256
  const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
960
1257
  const expectedWidth = spec.canvas.width * expectedScale;
@@ -1105,6 +1402,31 @@ async function runQa(options) {
1105
1402
  });
1106
1403
  }
1107
1404
  }
1405
+ let referenceResult;
1406
+ if (options.referencePath) {
1407
+ const { compareImages: compareImages2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
1408
+ const comparison = await compareImages2(options.referencePath, imagePath);
1409
+ referenceResult = {
1410
+ similarity: comparison.similarity,
1411
+ verdict: comparison.verdict,
1412
+ regions: comparison.regions.map((region) => ({
1413
+ label: region.label,
1414
+ similarity: region.similarity
1415
+ }))
1416
+ };
1417
+ if (comparison.verdict === "mismatch") {
1418
+ const severity = comparison.similarity < 0.5 ? "error" : "warning";
1419
+ issues.push({
1420
+ code: "REFERENCE_MISMATCH",
1421
+ severity,
1422
+ message: `Reference image comparison ${severity === "error" ? "failed" : "warned"}: similarity ${comparison.similarity.toFixed(4)} with verdict "${comparison.verdict}".`,
1423
+ details: {
1424
+ similarity: comparison.similarity,
1425
+ verdict: comparison.verdict
1426
+ }
1427
+ });
1428
+ }
1429
+ }
1108
1430
  const footerSpacingPx = options.metadata?.layout.elements ? (() => {
1109
1431
  const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
1110
1432
  if (!footer) {
@@ -1137,7 +1459,8 @@ async function runQa(options) {
1137
1459
  ...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
1138
1460
  ...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
1139
1461
  },
1140
- issues
1462
+ issues,
1463
+ ...referenceResult ? { reference: referenceResult } : {}
1141
1464
  };
1142
1465
  }
1143
1466
 
@@ -1175,89 +1498,484 @@ function loadFonts() {
1175
1498
  // src/layout/elk.ts
1176
1499
  import ELK from "elkjs";
1177
1500
 
1178
- // src/layout/estimates.ts
1179
- function estimateElementHeight(element) {
1180
- switch (element.type) {
1181
- case "card":
1182
- return 220;
1183
- case "flow-node":
1184
- return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
1185
- case "code-block":
1186
- return 260;
1187
- case "terminal":
1188
- return 245;
1189
- case "text":
1190
- return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
1191
- case "shape":
1192
- return 130;
1193
- case "image":
1194
- return 220;
1195
- case "connection":
1196
- return 0;
1197
- }
1501
+ // src/primitives/shapes.ts
1502
+ function roundRectPath(ctx, rect, radius) {
1503
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1504
+ const right = rect.x + rect.width;
1505
+ const bottom = rect.y + rect.height;
1506
+ ctx.beginPath();
1507
+ ctx.moveTo(rect.x + r, rect.y);
1508
+ ctx.lineTo(right - r, rect.y);
1509
+ ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1510
+ ctx.lineTo(right, bottom - r);
1511
+ ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1512
+ ctx.lineTo(rect.x + r, bottom);
1513
+ ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1514
+ ctx.lineTo(rect.x, rect.y + r);
1515
+ ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1516
+ ctx.closePath();
1198
1517
  }
1199
- function estimateElementWidth(element) {
1200
- switch (element.type) {
1201
- case "card":
1202
- return 320;
1203
- case "flow-node":
1204
- return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
1205
- case "code-block":
1206
- return 420;
1207
- case "terminal":
1208
- return 420;
1209
- case "text":
1210
- return 360;
1211
- case "shape":
1212
- return 280;
1213
- case "image":
1214
- return 320;
1215
- case "connection":
1216
- return 0;
1518
+ function fillAndStroke(ctx, fill, stroke) {
1519
+ ctx.fillStyle = fill;
1520
+ ctx.fill();
1521
+ if (stroke) {
1522
+ ctx.strokeStyle = stroke;
1523
+ ctx.stroke();
1217
1524
  }
1218
1525
  }
1219
-
1220
- // src/layout/stack.ts
1221
- function computeStackLayout(elements, config, safeFrame) {
1222
- const placeable = elements.filter((element) => element.type !== "connection");
1223
- const positions = /* @__PURE__ */ new Map();
1224
- if (placeable.length === 0) {
1225
- return { positions };
1226
- }
1227
- const gap = config.gap;
1228
- if (config.direction === "vertical") {
1229
- const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
1230
- const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
1231
- const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
1232
- const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
1233
- let y = safeFrame.y;
1234
- for (const [index, element] of placeable.entries()) {
1235
- const stretched = config.alignment === "stretch";
1236
- const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
1237
- const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
1238
- let x2 = safeFrame.x;
1239
- if (!stretched) {
1240
- if (config.alignment === "center") {
1241
- x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
1242
- } else if (config.alignment === "end") {
1243
- x2 = safeFrame.x + safeFrame.width - width;
1244
- }
1245
- }
1246
- positions.set(element.id, { x: x2, y, width, height });
1247
- y += height + gap;
1248
- }
1249
- return { positions };
1250
- }
1251
- const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
1252
- const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
1253
- const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
1254
- const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
1255
- let x = safeFrame.x;
1256
- for (const [index, element] of placeable.entries()) {
1257
- const stretched = config.alignment === "stretch";
1258
- const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
1259
- const width = Math.max(64, Math.floor(estimatedWidths[index] * scale));
1260
- let y = safeFrame.y;
1526
+ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1527
+ roundRectPath(ctx, rect, radius);
1528
+ fillAndStroke(ctx, fill, stroke);
1529
+ }
1530
+ function drawCircle(ctx, center, radius, fill, stroke) {
1531
+ ctx.beginPath();
1532
+ ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
1533
+ ctx.closePath();
1534
+ fillAndStroke(ctx, fill, stroke);
1535
+ }
1536
+ function drawDiamond(ctx, bounds, fill, stroke) {
1537
+ const cx = bounds.x + bounds.width / 2;
1538
+ const cy = bounds.y + bounds.height / 2;
1539
+ ctx.beginPath();
1540
+ ctx.moveTo(cx, bounds.y);
1541
+ ctx.lineTo(bounds.x + bounds.width, cy);
1542
+ ctx.lineTo(cx, bounds.y + bounds.height);
1543
+ ctx.lineTo(bounds.x, cy);
1544
+ ctx.closePath();
1545
+ fillAndStroke(ctx, fill, stroke);
1546
+ }
1547
+ function drawPill(ctx, bounds, fill, stroke) {
1548
+ drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1549
+ }
1550
+ function drawEllipse(ctx, bounds, fill, stroke) {
1551
+ const cx = bounds.x + bounds.width / 2;
1552
+ const cy = bounds.y + bounds.height / 2;
1553
+ ctx.beginPath();
1554
+ ctx.ellipse(
1555
+ cx,
1556
+ cy,
1557
+ Math.max(0, bounds.width / 2),
1558
+ Math.max(0, bounds.height / 2),
1559
+ 0,
1560
+ 0,
1561
+ Math.PI * 2
1562
+ );
1563
+ ctx.closePath();
1564
+ fillAndStroke(ctx, fill, stroke);
1565
+ }
1566
+ function drawCylinder(ctx, bounds, fill, stroke) {
1567
+ const rx = Math.max(2, bounds.width / 2);
1568
+ const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1569
+ const cx = bounds.x + bounds.width / 2;
1570
+ const topCy = bounds.y + ry;
1571
+ const bottomCy = bounds.y + bounds.height - ry;
1572
+ ctx.beginPath();
1573
+ ctx.moveTo(bounds.x, topCy);
1574
+ ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1575
+ ctx.lineTo(bounds.x + bounds.width, bottomCy);
1576
+ ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1577
+ ctx.closePath();
1578
+ fillAndStroke(ctx, fill, stroke);
1579
+ if (stroke) {
1580
+ ctx.beginPath();
1581
+ ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1582
+ ctx.closePath();
1583
+ ctx.strokeStyle = stroke;
1584
+ ctx.stroke();
1585
+ }
1586
+ }
1587
+ function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1588
+ const maxSkew = bounds.width * 0.45;
1589
+ const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1590
+ ctx.beginPath();
1591
+ ctx.moveTo(bounds.x + skewX, bounds.y);
1592
+ ctx.lineTo(bounds.x + bounds.width, bounds.y);
1593
+ ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1594
+ ctx.lineTo(bounds.x, bounds.y + bounds.height);
1595
+ ctx.closePath();
1596
+ fillAndStroke(ctx, fill, stroke);
1597
+ }
1598
+
1599
+ // src/primitives/text.ts
1600
+ var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1601
+ function resolveFont(requested, role) {
1602
+ if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1603
+ return requested;
1604
+ }
1605
+ if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1606
+ return "JetBrains Mono";
1607
+ }
1608
+ if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1609
+ return "Space Grotesk";
1610
+ }
1611
+ return "Inter";
1612
+ }
1613
+ function applyFont(ctx, options) {
1614
+ ctx.font = `${options.weight} ${options.size}px ${options.family}`;
1615
+ }
1616
+ function wrapText(ctx, text, maxWidth, maxLines) {
1617
+ const trimmed = text.trim();
1618
+ if (!trimmed) {
1619
+ return { lines: [], truncated: false };
1620
+ }
1621
+ const words = trimmed.split(/\s+/u);
1622
+ const lines = [];
1623
+ let current = "";
1624
+ for (const word of words) {
1625
+ const trial = current.length > 0 ? `${current} ${word}` : word;
1626
+ if (ctx.measureText(trial).width <= maxWidth) {
1627
+ current = trial;
1628
+ continue;
1629
+ }
1630
+ if (current.length > 0) {
1631
+ lines.push(current);
1632
+ current = word;
1633
+ } else {
1634
+ lines.push(word);
1635
+ current = "";
1636
+ }
1637
+ if (lines.length >= maxLines) {
1638
+ break;
1639
+ }
1640
+ }
1641
+ if (lines.length < maxLines && current.length > 0) {
1642
+ lines.push(current);
1643
+ }
1644
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
1645
+ if (!wasTruncated) {
1646
+ return { lines, truncated: false };
1647
+ }
1648
+ const lastIndex = lines.length - 1;
1649
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
1650
+ while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
1651
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
1652
+ }
1653
+ lines[lastIndex] = truncatedLine;
1654
+ return { lines, truncated: true };
1655
+ }
1656
+ function drawTextBlock(ctx, options) {
1657
+ applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
1658
+ const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
1659
+ ctx.fillStyle = options.color;
1660
+ for (const [index, line] of wrapped.lines.entries()) {
1661
+ ctx.fillText(line, options.x, options.y + index * options.lineHeight);
1662
+ }
1663
+ return {
1664
+ height: wrapped.lines.length * options.lineHeight,
1665
+ truncated: wrapped.truncated
1666
+ };
1667
+ }
1668
+ function drawTextLabel(ctx, text, position, options) {
1669
+ applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
1670
+ const textWidth = Math.ceil(ctx.measureText(text).width);
1671
+ const rect = {
1672
+ x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
1673
+ y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
1674
+ width: textWidth + options.padding * 2,
1675
+ height: options.fontSize + options.padding * 2
1676
+ };
1677
+ drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
1678
+ ctx.fillStyle = options.color;
1679
+ ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
1680
+ return rect;
1681
+ }
1682
+
1683
+ // src/renderers/flow-node.ts
1684
+ var BADGE_FONT_SIZE = 10;
1685
+ var BADGE_FONT_WEIGHT = 600;
1686
+ var BADGE_LETTER_SPACING = 1;
1687
+ var BADGE_PADDING_X = 8;
1688
+ var BADGE_PADDING_Y = 3;
1689
+ var BADGE_BORDER_RADIUS = 12;
1690
+ var BADGE_DEFAULT_COLOR = "#FFFFFF";
1691
+ var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
1692
+ var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
1693
+ function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
1694
+ switch (shape) {
1695
+ case "box":
1696
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
1697
+ break;
1698
+ case "rounded-box":
1699
+ drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
1700
+ break;
1701
+ case "diamond":
1702
+ drawDiamond(ctx, bounds, fill, stroke);
1703
+ break;
1704
+ case "circle": {
1705
+ const radius = Math.min(bounds.width, bounds.height) / 2;
1706
+ drawCircle(
1707
+ ctx,
1708
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
1709
+ radius,
1710
+ fill,
1711
+ stroke
1712
+ );
1713
+ break;
1714
+ }
1715
+ case "pill":
1716
+ drawPill(ctx, bounds, fill, stroke);
1717
+ break;
1718
+ case "cylinder":
1719
+ drawCylinder(ctx, bounds, fill, stroke);
1720
+ break;
1721
+ case "parallelogram":
1722
+ drawParallelogram(ctx, bounds, fill, stroke);
1723
+ break;
1724
+ }
1725
+ }
1726
+ function measureSpacedText(ctx, text, letterSpacing) {
1727
+ const base = ctx.measureText(text).width;
1728
+ const extraChars = [...text].length - 1;
1729
+ return extraChars > 0 ? base + extraChars * letterSpacing : base;
1730
+ }
1731
+ function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
1732
+ const chars = [...text];
1733
+ if (chars.length === 0) return;
1734
+ const totalWidth = measureSpacedText(ctx, text, letterSpacing);
1735
+ let cursorX = centerX - totalWidth / 2;
1736
+ ctx.textAlign = "left";
1737
+ for (let i = 0; i < chars.length; i++) {
1738
+ ctx.fillText(chars[i], cursorX, centerY);
1739
+ cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
1740
+ }
1741
+ }
1742
+ function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
1743
+ ctx.save();
1744
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1745
+ const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
1746
+ const pillWidth = textWidth + BADGE_PADDING_X * 2;
1747
+ const pillHeight = BADGE_PILL_HEIGHT;
1748
+ const pillX = centerX - pillWidth / 2;
1749
+ const pillY = centerY - pillHeight / 2;
1750
+ ctx.fillStyle = background;
1751
+ ctx.beginPath();
1752
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
1753
+ ctx.fill();
1754
+ ctx.fillStyle = textColor;
1755
+ ctx.textBaseline = "middle";
1756
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1757
+ drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
1758
+ ctx.restore();
1759
+ return pillWidth;
1760
+ }
1761
+ function renderFlowNode(ctx, node, bounds, theme) {
1762
+ const fillColor = node.color ?? theme.surfaceElevated;
1763
+ const borderColor = node.borderColor ?? theme.border;
1764
+ const borderWidth = node.borderWidth ?? 2;
1765
+ const cornerRadius = node.cornerRadius ?? 16;
1766
+ const labelColor = node.labelColor ?? theme.text;
1767
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
1768
+ const labelFontSize = node.labelFontSize ?? 20;
1769
+ const fillOpacity = node.fillOpacity ?? 1;
1770
+ const hasBadge = !!node.badgeText;
1771
+ const badgePosition = node.badgePosition ?? "inside-top";
1772
+ const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
1773
+ const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
1774
+ ctx.save();
1775
+ ctx.lineWidth = borderWidth;
1776
+ if (node.shadow) {
1777
+ const shadowColor = node.shadow.color ?? borderColor ?? theme.accent;
1778
+ ctx.shadowColor = withAlpha(shadowColor, node.shadow.opacity);
1779
+ ctx.shadowBlur = node.shadow.blur;
1780
+ ctx.shadowOffsetX = node.shadow.offsetX;
1781
+ ctx.shadowOffsetY = node.shadow.offsetY;
1782
+ }
1783
+ if (fillOpacity < 1) {
1784
+ ctx.globalAlpha = node.opacity * fillOpacity;
1785
+ drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
1786
+ if (node.shadow) {
1787
+ ctx.shadowColor = "transparent";
1788
+ ctx.shadowBlur = 0;
1789
+ ctx.shadowOffsetX = 0;
1790
+ ctx.shadowOffsetY = 0;
1791
+ }
1792
+ ctx.globalAlpha = node.opacity;
1793
+ drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
1794
+ } else {
1795
+ ctx.globalAlpha = node.opacity;
1796
+ drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
1797
+ }
1798
+ if (node.shadow) {
1799
+ ctx.shadowColor = "transparent";
1800
+ ctx.shadowBlur = 0;
1801
+ ctx.shadowOffsetX = 0;
1802
+ ctx.shadowOffsetY = 0;
1803
+ }
1804
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
1805
+ const bodyFont = resolveFont(theme.fonts.body, "body");
1806
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
1807
+ const centerX = bounds.x + bounds.width / 2;
1808
+ const centerY = bounds.y + bounds.height / 2;
1809
+ const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
1810
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
1811
+ const sublabel2FontSize = node.sublabel2FontSize ?? 11;
1812
+ const sublabel2Color = node.sublabel2Color ?? sublabelColor;
1813
+ const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
1814
+ const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
1815
+ const sublabelToSublabel2Gap = sublabel2FontSize + 4;
1816
+ let textBlockHeight;
1817
+ if (lineCount === 1) {
1818
+ textBlockHeight = labelFontSize;
1819
+ } else if (lineCount === 2) {
1820
+ textBlockHeight = labelFontSize + labelToSublabelGap;
1821
+ } else {
1822
+ textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
1823
+ }
1824
+ const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
1825
+ ctx.textAlign = "center";
1826
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
1827
+ ctx.fillStyle = labelColor;
1828
+ ctx.fillText(node.label, centerX, labelY);
1829
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
1830
+ let textBoundsHeight = 36;
1831
+ if (node.sublabel) {
1832
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
1833
+ ctx.fillStyle = sublabelColor;
1834
+ ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
1835
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
1836
+ textBoundsHeight = 56;
1837
+ }
1838
+ if (node.sublabel2) {
1839
+ applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
1840
+ ctx.fillStyle = sublabel2Color;
1841
+ const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
1842
+ ctx.fillText(node.sublabel2, centerX, sublabel2Y);
1843
+ textBoundsY = bounds.y + bounds.height / 2 - 30;
1844
+ textBoundsHeight = 72;
1845
+ }
1846
+ if (hasBadge && node.badgeText) {
1847
+ if (badgePosition === "inside-top") {
1848
+ const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
1849
+ renderBadgePill(
1850
+ ctx,
1851
+ centerX,
1852
+ badgeCenterY,
1853
+ node.badgeText,
1854
+ badgeColor,
1855
+ badgeBackground,
1856
+ monoFont
1857
+ );
1858
+ } else {
1859
+ const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
1860
+ renderBadgePill(
1861
+ ctx,
1862
+ centerX,
1863
+ badgeCenterY,
1864
+ node.badgeText,
1865
+ badgeColor,
1866
+ badgeBackground,
1867
+ monoFont
1868
+ );
1869
+ }
1870
+ }
1871
+ ctx.restore();
1872
+ const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
1873
+ return [
1874
+ {
1875
+ id: `flow-node-${node.id}`,
1876
+ kind: "flow-node",
1877
+ bounds,
1878
+ foregroundColor: labelColor,
1879
+ backgroundColor: effectiveBg
1880
+ },
1881
+ {
1882
+ id: `flow-node-${node.id}-label`,
1883
+ kind: "text",
1884
+ bounds: {
1885
+ x: bounds.x + 8,
1886
+ y: textBoundsY,
1887
+ width: bounds.width - 16,
1888
+ height: textBoundsHeight
1889
+ },
1890
+ foregroundColor: labelColor,
1891
+ backgroundColor: effectiveBg
1892
+ }
1893
+ ];
1894
+ }
1895
+
1896
+ // src/layout/estimates.ts
1897
+ function estimateElementHeight(element) {
1898
+ switch (element.type) {
1899
+ case "card":
1900
+ return 220;
1901
+ case "flow-node":
1902
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
1903
+ case "code-block":
1904
+ return 260;
1905
+ case "terminal":
1906
+ return 245;
1907
+ case "text":
1908
+ return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
1909
+ case "shape":
1910
+ return 130;
1911
+ case "image":
1912
+ return 220;
1913
+ case "connection":
1914
+ return 0;
1915
+ }
1916
+ }
1917
+ function estimateElementWidth(element) {
1918
+ switch (element.type) {
1919
+ case "card":
1920
+ return 320;
1921
+ case "flow-node":
1922
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
1923
+ case "code-block":
1924
+ return 420;
1925
+ case "terminal":
1926
+ return 420;
1927
+ case "text":
1928
+ return 360;
1929
+ case "shape":
1930
+ return 280;
1931
+ case "image":
1932
+ return 320;
1933
+ case "connection":
1934
+ return 0;
1935
+ }
1936
+ }
1937
+
1938
+ // src/layout/stack.ts
1939
+ function computeStackLayout(elements, config, safeFrame) {
1940
+ const placeable = elements.filter((element) => element.type !== "connection");
1941
+ const positions = /* @__PURE__ */ new Map();
1942
+ if (placeable.length === 0) {
1943
+ return { positions };
1944
+ }
1945
+ const gap = config.gap;
1946
+ if (config.direction === "vertical") {
1947
+ const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
1948
+ const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
1949
+ const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
1950
+ const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
1951
+ let y = safeFrame.y;
1952
+ for (const [index, element] of placeable.entries()) {
1953
+ const stretched = config.alignment === "stretch";
1954
+ const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
1955
+ const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
1956
+ let x2 = safeFrame.x;
1957
+ if (!stretched) {
1958
+ if (config.alignment === "center") {
1959
+ x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
1960
+ } else if (config.alignment === "end") {
1961
+ x2 = safeFrame.x + safeFrame.width - width;
1962
+ }
1963
+ }
1964
+ positions.set(element.id, { x: x2, y, width, height });
1965
+ y += height + gap;
1966
+ }
1967
+ return { positions };
1968
+ }
1969
+ const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
1970
+ const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
1971
+ const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
1972
+ const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
1973
+ let x = safeFrame.x;
1974
+ for (const [index, element] of placeable.entries()) {
1975
+ const stretched = config.alignment === "stretch";
1976
+ const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
1977
+ const width = Math.max(64, Math.floor(estimatedWidths[index] * scale));
1978
+ let y = safeFrame.y;
1261
1979
  if (!stretched) {
1262
1980
  if (config.alignment === "center") {
1263
1981
  y = safeFrame.y + Math.floor((safeFrame.height - height) / 2);
@@ -1273,33 +1991,37 @@ function computeStackLayout(elements, config, safeFrame) {
1273
1991
 
1274
1992
  // src/layout/elk.ts
1275
1993
  function estimateFlowNodeSize(node) {
1994
+ const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
1995
+ const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
1996
+ const extra = badgeExtra + sublabel2Extra;
1276
1997
  if (node.width && node.height) {
1277
- return { width: node.width, height: node.height };
1998
+ return { width: node.width, height: node.height + extra };
1278
1999
  }
1279
2000
  if (node.width) {
2001
+ const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
1280
2002
  return {
1281
2003
  width: node.width,
1282
- height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
2004
+ height: baseHeight + extra
1283
2005
  };
1284
2006
  }
1285
2007
  if (node.height) {
1286
2008
  return {
1287
2009
  width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
1288
- height: node.height
2010
+ height: node.height + extra
1289
2011
  };
1290
2012
  }
1291
2013
  switch (node.shape) {
1292
2014
  case "diamond":
1293
2015
  case "circle":
1294
- return { width: 100, height: 100 };
2016
+ return { width: 100 + extra, height: 100 + extra };
1295
2017
  case "pill":
1296
- return { width: 180, height: 56 };
2018
+ return { width: 180, height: 56 + extra };
1297
2019
  case "cylinder":
1298
- return { width: 140, height: 92 };
2020
+ return { width: 140, height: 92 + extra };
1299
2021
  case "parallelogram":
1300
- return { width: 180, height: 72 };
2022
+ return { width: 180, height: 72 + extra };
1301
2023
  default:
1302
- return { width: 170, height: 64 };
2024
+ return { width: 170, height: 64 + extra };
1303
2025
  }
1304
2026
  }
1305
2027
  function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
@@ -1417,6 +2139,40 @@ function directionToElk(direction) {
1417
2139
  return "DOWN";
1418
2140
  }
1419
2141
  }
2142
+ function radialCompactionToElk(compaction) {
2143
+ switch (compaction) {
2144
+ case "radial":
2145
+ return "RADIAL_COMPACTION";
2146
+ case "wedge":
2147
+ return "WEDGE_COMPACTION";
2148
+ default:
2149
+ return "NONE";
2150
+ }
2151
+ }
2152
+ function radialSortByToElk(sortBy) {
2153
+ switch (sortBy) {
2154
+ case "connections":
2155
+ return "POLAR_COORDINATE";
2156
+ default:
2157
+ return "ID";
2158
+ }
2159
+ }
2160
+ function buildRadialOptions(config) {
2161
+ const options = {};
2162
+ if (config.radialRoot) {
2163
+ options["elk.radial.centerOnRoot"] = "true";
2164
+ }
2165
+ if (config.radialRadius != null) {
2166
+ options["elk.radial.radius"] = String(config.radialRadius);
2167
+ }
2168
+ if (config.radialCompaction) {
2169
+ options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
2170
+ }
2171
+ if (config.radialSortBy) {
2172
+ options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
2173
+ }
2174
+ return options;
2175
+ }
1420
2176
  function fallbackForNoFlowNodes(nonFlow, safeFrame) {
1421
2177
  const fallbackConfig = {
1422
2178
  mode: "stack",
@@ -1452,6 +2208,11 @@ async function computeElkLayout(elements, config, safeFrame) {
1452
2208
  elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
1453
2209
  }
1454
2210
  const edgeIdToRouteKey = /* @__PURE__ */ new Map();
2211
+ const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
2212
+ const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
2213
+ ...flowNodes.filter((node) => node.id === config.radialRoot),
2214
+ ...flowNodes.filter((node) => node.id !== config.radialRoot)
2215
+ ] : flowNodes;
1455
2216
  const elkGraph = {
1456
2217
  id: "root",
1457
2218
  layoutOptions: {
@@ -1461,9 +2222,10 @@ async function computeElkLayout(elements, config, safeFrame) {
1461
2222
  "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
1462
2223
  "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
1463
2224
  ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
1464
- ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
2225
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
2226
+ ...radialOptions
1465
2227
  },
1466
- children: flowNodes.map((node) => {
2228
+ children: orderedFlowNodes.map((node) => {
1467
2229
  const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
1468
2230
  return {
1469
2231
  id: node.id,
@@ -1700,262 +2462,80 @@ function roundedRectPath(ctx, x, y, width, height, radius) {
1700
2462
  function parseHexColor2(color) {
1701
2463
  const normalized = color.startsWith("#") ? color.slice(1) : color;
1702
2464
  if (normalized.length !== 6 && normalized.length !== 8) {
1703
- throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
1704
- }
1705
- const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
1706
- return {
1707
- r: parseChannel2(0),
1708
- g: parseChannel2(2),
1709
- b: parseChannel2(4),
1710
- a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
1711
- };
1712
- }
1713
- function withAlpha(color, alpha) {
1714
- const parsed = parseHexColor2(color);
1715
- const effectiveAlpha = clamp01(parsed.a * alpha);
1716
- return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
1717
- }
1718
- function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
1719
- const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
1720
- rect.x + rect.width / 2,
1721
- rect.y + rect.height / 2,
1722
- 0,
1723
- rect.x + rect.width / 2,
1724
- rect.y + rect.height / 2,
1725
- Math.max(rect.width, rect.height) / 2
1726
- );
1727
- addGradientStops(fill, gradient.stops);
1728
- ctx.save();
1729
- ctx.fillStyle = fill;
1730
- if (borderRadius > 0) {
1731
- roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
1732
- ctx.fill();
1733
- } else {
1734
- ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
1735
- }
1736
- ctx.restore();
1737
- }
1738
- function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
1739
- if (width <= 0 || thickness <= 0) {
1740
- return;
1741
- }
1742
- const gradient = ctx.createLinearGradient(x, y, x + width, y);
1743
- const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
1744
- for (const [index, color] of stops.entries()) {
1745
- gradient.addColorStop(index / (stops.length - 1), color);
1746
- }
1747
- const ruleTop = y - thickness / 2;
1748
- ctx.save();
1749
- roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
1750
- ctx.fillStyle = gradient;
1751
- ctx.fill();
1752
- ctx.restore();
1753
- }
1754
- function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1755
- if (width <= 0 || height <= 0 || intensity <= 0) {
1756
- return;
1757
- }
1758
- const centerX = width / 2;
1759
- const centerY = height / 2;
1760
- const outerRadius = Math.max(width, height) / 2;
1761
- const innerRadius = Math.min(width, height) * 0.2;
1762
- const vignette = ctx.createRadialGradient(
1763
- centerX,
1764
- centerY,
1765
- innerRadius,
1766
- centerX,
1767
- centerY,
1768
- outerRadius
1769
- );
1770
- vignette.addColorStop(0, withAlpha(color, 0));
1771
- vignette.addColorStop(0.6, withAlpha(color, 0));
1772
- vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
1773
- ctx.save();
1774
- ctx.fillStyle = vignette;
1775
- ctx.fillRect(0, 0, width, height);
1776
- ctx.restore();
1777
- }
1778
-
1779
- // src/primitives/shapes.ts
1780
- function roundRectPath(ctx, rect, radius) {
1781
- const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1782
- const right = rect.x + rect.width;
1783
- const bottom = rect.y + rect.height;
1784
- ctx.beginPath();
1785
- ctx.moveTo(rect.x + r, rect.y);
1786
- ctx.lineTo(right - r, rect.y);
1787
- ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1788
- ctx.lineTo(right, bottom - r);
1789
- ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1790
- ctx.lineTo(rect.x + r, bottom);
1791
- ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1792
- ctx.lineTo(rect.x, rect.y + r);
1793
- ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1794
- ctx.closePath();
1795
- }
1796
- function fillAndStroke(ctx, fill, stroke) {
1797
- ctx.fillStyle = fill;
1798
- ctx.fill();
1799
- if (stroke) {
1800
- ctx.strokeStyle = stroke;
1801
- ctx.stroke();
1802
- }
1803
- }
1804
- function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1805
- roundRectPath(ctx, rect, radius);
1806
- fillAndStroke(ctx, fill, stroke);
1807
- }
1808
- function drawCircle(ctx, center2, radius, fill, stroke) {
1809
- ctx.beginPath();
1810
- ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1811
- ctx.closePath();
1812
- fillAndStroke(ctx, fill, stroke);
1813
- }
1814
- function drawDiamond(ctx, bounds, fill, stroke) {
1815
- const cx = bounds.x + bounds.width / 2;
1816
- const cy = bounds.y + bounds.height / 2;
1817
- ctx.beginPath();
1818
- ctx.moveTo(cx, bounds.y);
1819
- ctx.lineTo(bounds.x + bounds.width, cy);
1820
- ctx.lineTo(cx, bounds.y + bounds.height);
1821
- ctx.lineTo(bounds.x, cy);
1822
- ctx.closePath();
1823
- fillAndStroke(ctx, fill, stroke);
1824
- }
1825
- function drawPill(ctx, bounds, fill, stroke) {
1826
- drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1827
- }
1828
- function drawEllipse(ctx, bounds, fill, stroke) {
1829
- const cx = bounds.x + bounds.width / 2;
1830
- const cy = bounds.y + bounds.height / 2;
1831
- ctx.beginPath();
1832
- ctx.ellipse(
1833
- cx,
1834
- cy,
1835
- Math.max(0, bounds.width / 2),
1836
- Math.max(0, bounds.height / 2),
1837
- 0,
1838
- 0,
1839
- Math.PI * 2
1840
- );
1841
- ctx.closePath();
1842
- fillAndStroke(ctx, fill, stroke);
1843
- }
1844
- function drawCylinder(ctx, bounds, fill, stroke) {
1845
- const rx = Math.max(2, bounds.width / 2);
1846
- const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1847
- const cx = bounds.x + bounds.width / 2;
1848
- const topCy = bounds.y + ry;
1849
- const bottomCy = bounds.y + bounds.height - ry;
1850
- ctx.beginPath();
1851
- ctx.moveTo(bounds.x, topCy);
1852
- ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1853
- ctx.lineTo(bounds.x + bounds.width, bottomCy);
1854
- ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1855
- ctx.closePath();
1856
- fillAndStroke(ctx, fill, stroke);
1857
- if (stroke) {
1858
- ctx.beginPath();
1859
- ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1860
- ctx.closePath();
1861
- ctx.strokeStyle = stroke;
1862
- ctx.stroke();
1863
- }
1864
- }
1865
- function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1866
- const maxSkew = bounds.width * 0.45;
1867
- const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1868
- ctx.beginPath();
1869
- ctx.moveTo(bounds.x + skewX, bounds.y);
1870
- ctx.lineTo(bounds.x + bounds.width, bounds.y);
1871
- ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1872
- ctx.lineTo(bounds.x, bounds.y + bounds.height);
1873
- ctx.closePath();
1874
- fillAndStroke(ctx, fill, stroke);
1875
- }
1876
-
1877
- // src/primitives/text.ts
1878
- var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1879
- function resolveFont(requested, role) {
1880
- if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1881
- return requested;
1882
- }
1883
- if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1884
- return "JetBrains Mono";
1885
- }
1886
- if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1887
- return "Space Grotesk";
2465
+ throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
1888
2466
  }
1889
- return "Inter";
2467
+ const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
2468
+ return {
2469
+ r: parseChannel2(0),
2470
+ g: parseChannel2(2),
2471
+ b: parseChannel2(4),
2472
+ a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
2473
+ };
1890
2474
  }
1891
- function applyFont(ctx, options) {
1892
- ctx.font = `${options.weight} ${options.size}px ${options.family}`;
2475
+ function withAlpha2(color, alpha) {
2476
+ const parsed = parseHexColor2(color);
2477
+ const effectiveAlpha = clamp01(parsed.a * alpha);
2478
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
1893
2479
  }
1894
- function wrapText(ctx, text, maxWidth, maxLines) {
1895
- const trimmed = text.trim();
1896
- if (!trimmed) {
1897
- return { lines: [], truncated: false };
1898
- }
1899
- const words = trimmed.split(/\s+/u);
1900
- const lines = [];
1901
- let current = "";
1902
- for (const word of words) {
1903
- const trial = current.length > 0 ? `${current} ${word}` : word;
1904
- if (ctx.measureText(trial).width <= maxWidth) {
1905
- current = trial;
1906
- continue;
1907
- }
1908
- if (current.length > 0) {
1909
- lines.push(current);
1910
- current = word;
1911
- } else {
1912
- lines.push(word);
1913
- current = "";
1914
- }
1915
- if (lines.length >= maxLines) {
1916
- break;
1917
- }
1918
- }
1919
- if (lines.length < maxLines && current.length > 0) {
1920
- lines.push(current);
2480
+ function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
2481
+ const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
2482
+ rect.x + rect.width / 2,
2483
+ rect.y + rect.height / 2,
2484
+ 0,
2485
+ rect.x + rect.width / 2,
2486
+ rect.y + rect.height / 2,
2487
+ Math.max(rect.width, rect.height) / 2
2488
+ );
2489
+ addGradientStops(fill, gradient.stops);
2490
+ ctx.save();
2491
+ ctx.fillStyle = fill;
2492
+ if (borderRadius > 0) {
2493
+ roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
2494
+ ctx.fill();
2495
+ } else {
2496
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
1921
2497
  }
1922
- const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
1923
- if (!wasTruncated) {
1924
- return { lines, truncated: false };
2498
+ ctx.restore();
2499
+ }
2500
+ function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
2501
+ if (width <= 0 || thickness <= 0) {
2502
+ return;
1925
2503
  }
1926
- const lastIndex = lines.length - 1;
1927
- let truncatedLine = `${lines[lastIndex]}\u2026`;
1928
- while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
1929
- truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
2504
+ const gradient = ctx.createLinearGradient(x, y, x + width, y);
2505
+ const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
2506
+ for (const [index, color] of stops.entries()) {
2507
+ gradient.addColorStop(index / (stops.length - 1), color);
1930
2508
  }
1931
- lines[lastIndex] = truncatedLine;
1932
- return { lines, truncated: true };
2509
+ const ruleTop = y - thickness / 2;
2510
+ ctx.save();
2511
+ roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
2512
+ ctx.fillStyle = gradient;
2513
+ ctx.fill();
2514
+ ctx.restore();
1933
2515
  }
1934
- function drawTextBlock(ctx, options) {
1935
- applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
1936
- const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
1937
- ctx.fillStyle = options.color;
1938
- for (const [index, line] of wrapped.lines.entries()) {
1939
- ctx.fillText(line, options.x, options.y + index * options.lineHeight);
2516
+ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
2517
+ if (width <= 0 || height <= 0 || intensity <= 0) {
2518
+ return;
1940
2519
  }
1941
- return {
1942
- height: wrapped.lines.length * options.lineHeight,
1943
- truncated: wrapped.truncated
1944
- };
1945
- }
1946
- function drawTextLabel(ctx, text, position, options) {
1947
- applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
1948
- const textWidth = Math.ceil(ctx.measureText(text).width);
1949
- const rect = {
1950
- x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
1951
- y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
1952
- width: textWidth + options.padding * 2,
1953
- height: options.fontSize + options.padding * 2
1954
- };
1955
- drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
1956
- ctx.fillStyle = options.color;
1957
- ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
1958
- return rect;
2520
+ const centerX = width / 2;
2521
+ const centerY = height / 2;
2522
+ const outerRadius = Math.max(width, height) / 2;
2523
+ const innerRadius = Math.min(width, height) * 0.2;
2524
+ const vignette = ctx.createRadialGradient(
2525
+ centerX,
2526
+ centerY,
2527
+ innerRadius,
2528
+ centerX,
2529
+ centerY,
2530
+ outerRadius
2531
+ );
2532
+ vignette.addColorStop(0, withAlpha2(color, 0));
2533
+ vignette.addColorStop(0.6, withAlpha2(color, 0));
2534
+ vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
2535
+ ctx.save();
2536
+ ctx.fillStyle = vignette;
2537
+ ctx.fillRect(0, 0, width, height);
2538
+ ctx.restore();
1959
2539
  }
1960
2540
 
1961
2541
  // src/renderers/card.ts
@@ -2082,12 +2662,12 @@ var MACOS_DOTS = [
2082
2662
  { fill: "#27C93F", stroke: "#1AAB29" }
2083
2663
  ];
2084
2664
  function drawMacosDots(ctx, x, y) {
2085
- for (const [index, dot] of MACOS_DOTS.entries()) {
2665
+ for (const [index, dot2] of MACOS_DOTS.entries()) {
2086
2666
  ctx.beginPath();
2087
2667
  ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2088
2668
  ctx.closePath();
2089
- ctx.fillStyle = dot.fill;
2090
- ctx.strokeStyle = dot.stroke;
2669
+ ctx.fillStyle = dot2.fill;
2670
+ ctx.strokeStyle = dot2.stroke;
2091
2671
  ctx.lineWidth = DOT_STROKE_WIDTH;
2092
2672
  ctx.fill();
2093
2673
  ctx.stroke();
@@ -2504,25 +3084,134 @@ function drawOrthogonalPath(ctx, from, to, style) {
2504
3084
  }
2505
3085
 
2506
3086
  // src/renderers/connection.ts
2507
- function center(rect) {
3087
+ var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
3088
+ function rectCenter(rect) {
2508
3089
  return {
2509
3090
  x: rect.x + rect.width / 2,
2510
3091
  y: rect.y + rect.height / 2
2511
3092
  };
2512
3093
  }
2513
- function edgeAnchor(rect, target) {
2514
- const c = center(rect);
3094
+ function edgeAnchor(bounds, target) {
3095
+ const c = rectCenter(bounds);
2515
3096
  const dx = target.x - c.x;
2516
3097
  const dy = target.y - c.y;
2517
- if (Math.abs(dx) >= Math.abs(dy)) {
2518
- return {
2519
- x: dx >= 0 ? rect.x + rect.width : rect.x,
2520
- y: c.y
2521
- };
3098
+ if (dx === 0 && dy === 0) {
3099
+ return { x: c.x, y: c.y - bounds.height / 2 };
3100
+ }
3101
+ const hw = bounds.width / 2;
3102
+ const hh = bounds.height / 2;
3103
+ const absDx = Math.abs(dx);
3104
+ const absDy = Math.abs(dy);
3105
+ const t = absDx * hh > absDy * hw ? hw / absDx : hh / absDy;
3106
+ return { x: c.x + dx * t, y: c.y + dy * t };
3107
+ }
3108
+ function outwardNormal(point, diagramCenter) {
3109
+ const dx = point.x - diagramCenter.x;
3110
+ const dy = point.y - diagramCenter.y;
3111
+ const len = Math.hypot(dx, dy) || 1;
3112
+ return { x: dx / len, y: dy / len };
3113
+ }
3114
+ function curveRoute(fromBounds, toBounds, diagramCenter, tension) {
3115
+ const fromCenter = rectCenter(fromBounds);
3116
+ const toCenter = rectCenter(toBounds);
3117
+ const p0 = edgeAnchor(fromBounds, toCenter);
3118
+ const p3 = edgeAnchor(toBounds, fromCenter);
3119
+ const dist = Math.hypot(p3.x - p0.x, p3.y - p0.y);
3120
+ const offset = dist * tension;
3121
+ const n0 = outwardNormal(p0, diagramCenter);
3122
+ const n3 = outwardNormal(p3, diagramCenter);
3123
+ const cp1 = { x: p0.x + n0.x * offset, y: p0.y + n0.y * offset };
3124
+ const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
3125
+ return [p0, cp1, cp2, p3];
3126
+ }
3127
+ function dot(a, b) {
3128
+ return a.x * b.x + a.y * b.y;
3129
+ }
3130
+ function localToWorld(origin, axisX, axisY, local) {
3131
+ return {
3132
+ x: origin.x + axisX.x * local.x + axisY.x * local.y,
3133
+ y: origin.y + axisX.y * local.x + axisY.y * local.y
3134
+ };
3135
+ }
3136
+ function arcRoute(fromBounds, toBounds, diagramCenter, tension) {
3137
+ const fromCenter = rectCenter(fromBounds);
3138
+ const toCenter = rectCenter(toBounds);
3139
+ const start = edgeAnchor(fromBounds, toCenter);
3140
+ const end = edgeAnchor(toBounds, fromCenter);
3141
+ const chord = { x: end.x - start.x, y: end.y - start.y };
3142
+ const chordLength = Math.hypot(chord.x, chord.y);
3143
+ if (chordLength < 1e-6) {
3144
+ const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3145
+ return [
3146
+ [start, start, mid, mid],
3147
+ [mid, mid, end, end]
3148
+ ];
3149
+ }
3150
+ const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
3151
+ let axisY = { x: -axisX.y, y: axisX.x };
3152
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3153
+ const outwardHint = outwardNormal(midpoint, diagramCenter);
3154
+ if (dot(axisY, outwardHint) < 0) {
3155
+ axisY = { x: -axisY.x, y: -axisY.y };
3156
+ }
3157
+ const semiMajor = chordLength / 2;
3158
+ const semiMinor = Math.max(12, chordLength * tension * 0.75);
3159
+ const p0Local = { x: -semiMajor, y: 0 };
3160
+ const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3161
+ const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3162
+ const pMidLocal = { x: 0, y: semiMinor };
3163
+ const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3164
+ const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3165
+ const p3Local = { x: semiMajor, y: 0 };
3166
+ const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
3167
+ const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
3168
+ const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
3169
+ const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
3170
+ const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
3171
+ const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
3172
+ const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
3173
+ return [
3174
+ [p0, cp1, cp2, pMid],
3175
+ [pMid, cp3, cp4, p3]
3176
+ ];
3177
+ }
3178
+ function orthogonalRoute(fromBounds, toBounds) {
3179
+ const fromC = rectCenter(fromBounds);
3180
+ const toC = rectCenter(toBounds);
3181
+ const p0 = edgeAnchor(fromBounds, toC);
3182
+ const p3 = edgeAnchor(toBounds, fromC);
3183
+ const midX = (p0.x + p3.x) / 2;
3184
+ return [p0, { x: midX, y: p0.y }, { x: midX, y: p3.y }, p3];
3185
+ }
3186
+ function bezierPointAt(p0, cp1, cp2, p3, t) {
3187
+ const mt = 1 - t;
3188
+ return {
3189
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * p3.x,
3190
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * p3.y
3191
+ };
3192
+ }
3193
+ function pointAlongArc(route, t) {
3194
+ const [first, second] = route;
3195
+ if (t <= 0.5) {
3196
+ const localT2 = Math.max(0, Math.min(1, t * 2));
3197
+ return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
3198
+ }
3199
+ const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
3200
+ return bezierPointAt(second[0], second[1], second[2], second[3], localT);
3201
+ }
3202
+ function computeDiagramCenter(nodeBounds, canvasCenter) {
3203
+ if (nodeBounds.length === 0) {
3204
+ return canvasCenter ?? { x: 0, y: 0 };
3205
+ }
3206
+ let totalX = 0;
3207
+ let totalY = 0;
3208
+ for (const bounds of nodeBounds) {
3209
+ totalX += bounds.x + bounds.width / 2;
3210
+ totalY += bounds.y + bounds.height / 2;
2522
3211
  }
2523
3212
  return {
2524
- x: c.x,
2525
- y: dy >= 0 ? rect.y + rect.height : rect.y
3213
+ x: totalX / nodeBounds.length,
3214
+ y: totalY / nodeBounds.length
2526
3215
  };
2527
3216
  }
2528
3217
  function dashFromStyle(style) {
@@ -2606,51 +3295,95 @@ function polylineBounds(points) {
2606
3295
  height: Math.max(1, maxY - minY)
2607
3296
  };
2608
3297
  }
2609
- function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
2610
- const fromCenter = center(fromBounds);
2611
- const toCenter = center(toBounds);
2612
- const from = edgeAnchor(fromBounds, toCenter);
2613
- const to = edgeAnchor(toBounds, fromCenter);
2614
- const dash = dashFromStyle(conn.style);
3298
+ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
3299
+ const routing = conn.routing ?? "auto";
3300
+ const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
3301
+ const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
3302
+ const tension = conn.tension ?? 0.35;
3303
+ const dash = dashFromStyle(strokeStyle);
2615
3304
  const style = {
2616
3305
  color: conn.color ?? theme.borderMuted,
2617
- width: conn.width ?? 2,
3306
+ width: strokeWidth,
2618
3307
  headSize: conn.arrowSize ?? 10,
2619
3308
  ...dash ? { dash } : {}
2620
3309
  };
2621
- const points = edgeRoute && edgeRoute.points.length >= 2 ? edgeRoute.points : [from, { x: (from.x + to.x) / 2, y: from.y }, { x: (from.x + to.x) / 2, y: to.y }, to];
2622
- const startSegment = points[1] ?? points[0];
2623
- const endStart = points[points.length - 2] ?? points[0];
2624
- const end = points[points.length - 1] ?? points[0];
2625
- let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
2626
- let endAngle = Math.atan2(end.y - endStart.y, end.x - endStart.x);
3310
+ const labelT = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
3311
+ const diagramCenter = options?.diagramCenter ?? computeDiagramCenter([fromBounds, toBounds]);
3312
+ let linePoints;
3313
+ let startPoint;
3314
+ let endPoint;
3315
+ let startAngle;
3316
+ let endAngle;
3317
+ let labelPoint;
3318
+ ctx.save();
3319
+ ctx.globalAlpha = conn.opacity;
3320
+ if (routing === "curve") {
3321
+ const [p0, cp1, cp2, p3] = curveRoute(fromBounds, toBounds, diagramCenter, tension);
3322
+ ctx.strokeStyle = style.color;
3323
+ ctx.lineWidth = style.width;
3324
+ ctx.setLineDash(style.dash ?? []);
3325
+ ctx.beginPath();
3326
+ ctx.moveTo(p0.x, p0.y);
3327
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p3.x, p3.y);
3328
+ ctx.stroke();
3329
+ linePoints = [p0, cp1, cp2, p3];
3330
+ startPoint = p0;
3331
+ endPoint = p3;
3332
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3333
+ endAngle = Math.atan2(p3.y - cp2.y, p3.x - cp2.x);
3334
+ labelPoint = bezierPointAt(p0, cp1, cp2, p3, labelT);
3335
+ } else if (routing === "arc") {
3336
+ const [first, second] = arcRoute(fromBounds, toBounds, diagramCenter, tension);
3337
+ const [p0, cp1, cp2, pMid] = first;
3338
+ const [, cp3, cp4, p3] = second;
3339
+ ctx.strokeStyle = style.color;
3340
+ ctx.lineWidth = style.width;
3341
+ ctx.setLineDash(style.dash ?? []);
3342
+ ctx.beginPath();
3343
+ ctx.moveTo(p0.x, p0.y);
3344
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
3345
+ ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
3346
+ ctx.stroke();
3347
+ linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
3348
+ startPoint = p0;
3349
+ endPoint = p3;
3350
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3351
+ endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
3352
+ labelPoint = pointAlongArc([first, second], labelT);
3353
+ } else {
3354
+ const useElkRoute = routing === "auto" && (edgeRoute?.points.length ?? 0) >= 2;
3355
+ linePoints = useElkRoute ? edgeRoute?.points ?? orthogonalRoute(fromBounds, toBounds) : orthogonalRoute(fromBounds, toBounds);
3356
+ startPoint = linePoints[0];
3357
+ const startSegment = linePoints[1] ?? linePoints[0];
3358
+ const endStart = linePoints[linePoints.length - 2] ?? linePoints[0];
3359
+ endPoint = linePoints[linePoints.length - 1] ?? linePoints[0];
3360
+ startAngle = Math.atan2(startSegment.y - linePoints[0].y, startSegment.x - linePoints[0].x) + Math.PI;
3361
+ endAngle = Math.atan2(endPoint.y - endStart.y, endPoint.x - endStart.x);
3362
+ if (useElkRoute) {
3363
+ drawCubicInterpolatedPath(ctx, linePoints, style);
3364
+ } else {
3365
+ drawOrthogonalPath(ctx, startPoint, endPoint, style);
3366
+ }
3367
+ labelPoint = pointAlongPolyline(linePoints, labelT);
3368
+ }
2627
3369
  if (!Number.isFinite(startAngle)) {
2628
3370
  startAngle = 0;
2629
3371
  }
2630
3372
  if (!Number.isFinite(endAngle)) {
2631
3373
  endAngle = 0;
2632
3374
  }
2633
- const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
2634
- const labelPoint = pointAlongPolyline(points, t);
2635
- ctx.save();
2636
- ctx.globalAlpha = conn.opacity;
2637
- if (edgeRoute && edgeRoute.points.length >= 2) {
2638
- drawCubicInterpolatedPath(ctx, points, style);
2639
- } else {
2640
- drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
2641
- }
2642
3375
  if (conn.arrow === "start" || conn.arrow === "both") {
2643
- drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
3376
+ drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
2644
3377
  }
2645
3378
  if (conn.arrow === "end" || conn.arrow === "both") {
2646
- drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
3379
+ drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
2647
3380
  }
2648
3381
  ctx.restore();
2649
3382
  const elements = [
2650
3383
  {
2651
3384
  id: `connection-${conn.from}-${conn.to}`,
2652
3385
  kind: "connection",
2653
- bounds: polylineBounds(points),
3386
+ bounds: polylineBounds(linePoints),
2654
3387
  foregroundColor: style.color
2655
3388
  }
2656
3389
  ];
@@ -3281,92 +4014,6 @@ function renderDrawCommands(ctx, commands, theme) {
3281
4014
  return rendered;
3282
4015
  }
3283
4016
 
3284
- // src/renderers/flow-node.ts
3285
- function renderFlowNode(ctx, node, bounds, theme) {
3286
- const fillColor = node.color ?? theme.surfaceElevated;
3287
- const borderColor = node.borderColor ?? theme.border;
3288
- const borderWidth = node.borderWidth ?? 2;
3289
- const cornerRadius = node.cornerRadius ?? 16;
3290
- const labelColor = node.labelColor ?? theme.text;
3291
- const sublabelColor = node.sublabelColor ?? theme.textMuted;
3292
- const labelFontSize = node.labelFontSize ?? 20;
3293
- ctx.save();
3294
- ctx.globalAlpha = node.opacity;
3295
- ctx.lineWidth = borderWidth;
3296
- switch (node.shape) {
3297
- case "box":
3298
- drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
3299
- break;
3300
- case "rounded-box":
3301
- drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
3302
- break;
3303
- case "diamond":
3304
- drawDiamond(ctx, bounds, fillColor, borderColor);
3305
- break;
3306
- case "circle": {
3307
- const radius = Math.min(bounds.width, bounds.height) / 2;
3308
- drawCircle(
3309
- ctx,
3310
- { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
3311
- radius,
3312
- fillColor,
3313
- borderColor
3314
- );
3315
- break;
3316
- }
3317
- case "pill":
3318
- drawPill(ctx, bounds, fillColor, borderColor);
3319
- break;
3320
- case "cylinder":
3321
- drawCylinder(ctx, bounds, fillColor, borderColor);
3322
- break;
3323
- case "parallelogram":
3324
- drawParallelogram(ctx, bounds, fillColor, borderColor);
3325
- break;
3326
- }
3327
- const headingFont = resolveFont(theme.fonts.heading, "heading");
3328
- const bodyFont = resolveFont(theme.fonts.body, "body");
3329
- const centerX = bounds.x + bounds.width / 2;
3330
- const centerY = bounds.y + bounds.height / 2;
3331
- const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
3332
- ctx.textAlign = "center";
3333
- applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
3334
- ctx.fillStyle = labelColor;
3335
- ctx.fillText(node.label, centerX, labelY);
3336
- let textBoundsY = bounds.y + bounds.height / 2 - 18;
3337
- let textBoundsHeight = 36;
3338
- if (node.sublabel) {
3339
- const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
3340
- applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
3341
- ctx.fillStyle = sublabelColor;
3342
- ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
3343
- textBoundsY = bounds.y + bounds.height / 2 - 24;
3344
- textBoundsHeight = 56;
3345
- }
3346
- ctx.restore();
3347
- return [
3348
- {
3349
- id: `flow-node-${node.id}`,
3350
- kind: "flow-node",
3351
- bounds,
3352
- foregroundColor: labelColor,
3353
- backgroundColor: fillColor
3354
- },
3355
- {
3356
- id: `flow-node-${node.id}-label`,
3357
- kind: "text",
3358
- bounds: {
3359
- x: bounds.x + 8,
3360
- y: textBoundsY,
3361
- width: bounds.width - 16,
3362
- height: textBoundsHeight
3363
- },
3364
- foregroundColor: labelColor,
3365
- backgroundColor: fillColor
3366
- }
3367
- ];
3368
- }
3369
-
3370
4017
  // src/renderers/image.ts
3371
4018
  import { loadImage } from "@napi-rs/canvas";
3372
4019
  function roundedRectPath2(ctx, bounds, radius) {
@@ -3950,6 +4597,10 @@ async function renderDesign(input, options = {}) {
3950
4597
  break;
3951
4598
  }
3952
4599
  }
4600
+ const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
4601
+ spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
4602
+ { x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
4603
+ );
3953
4604
  for (const element of spec.elements) {
3954
4605
  if (element.type !== "connection") {
3955
4606
  continue;
@@ -3962,7 +4613,9 @@ async function renderDesign(input, options = {}) {
3962
4613
  );
3963
4614
  }
3964
4615
  const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
3965
- elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
4616
+ elements.push(
4617
+ ...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
4618
+ );
3966
4619
  }
3967
4620
  if (footerRect && spec.footer) {
3968
4621
  const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
@@ -4325,6 +4978,36 @@ var renderOutputSchema = z3.object({
4325
4978
  )
4326
4979
  })
4327
4980
  });
4981
+ var compareOutputSchema = z3.object({
4982
+ targetPath: z3.string(),
4983
+ renderedPath: z3.string(),
4984
+ targetDimensions: z3.object({
4985
+ width: z3.number().int().positive(),
4986
+ height: z3.number().int().positive()
4987
+ }),
4988
+ renderedDimensions: z3.object({
4989
+ width: z3.number().int().positive(),
4990
+ height: z3.number().int().positive()
4991
+ }),
4992
+ normalizedDimensions: z3.object({
4993
+ width: z3.number().int().positive(),
4994
+ height: z3.number().int().positive()
4995
+ }),
4996
+ dimensionMismatch: z3.boolean(),
4997
+ grid: z3.number().int().positive(),
4998
+ threshold: z3.number(),
4999
+ closeThreshold: z3.number(),
5000
+ similarity: z3.number(),
5001
+ verdict: z3.enum(["match", "close", "mismatch"]),
5002
+ regions: z3.array(
5003
+ z3.object({
5004
+ label: z3.string(),
5005
+ row: z3.number().int().nonnegative(),
5006
+ column: z3.number().int().nonnegative(),
5007
+ similarity: z3.number()
5008
+ })
5009
+ )
5010
+ });
4328
5011
  async function readJson(path) {
4329
5012
  if (path === "-") {
4330
5013
  const chunks = [];
@@ -4427,6 +5110,44 @@ cli.command("render", {
4427
5110
  return c.ok(runReport);
4428
5111
  }
4429
5112
  });
5113
+ cli.command("compare", {
5114
+ description: "Compare a rendered design against a target image using structural similarity scoring.",
5115
+ options: z3.object({
5116
+ target: z3.string().describe("Path to target image (baseline)"),
5117
+ rendered: z3.string().describe("Path to rendered image to evaluate"),
5118
+ grid: z3.number().int().positive().default(3).describe("Grid size for per-region scoring"),
5119
+ threshold: z3.number().min(0).max(1).default(0.8).describe("Minimum similarity score required for a match verdict")
5120
+ }),
5121
+ output: compareOutputSchema,
5122
+ examples: [
5123
+ {
5124
+ options: {
5125
+ target: "./designs/target.png",
5126
+ rendered: "./output/design-v2-g0.4.0-sabc123.png",
5127
+ grid: 3,
5128
+ threshold: 0.8
5129
+ },
5130
+ description: "Compare two images and report overall + per-region similarity scores"
5131
+ }
5132
+ ],
5133
+ async run(c) {
5134
+ try {
5135
+ return c.ok(
5136
+ await compareImages(c.options.target, c.options.rendered, {
5137
+ grid: c.options.grid,
5138
+ threshold: c.options.threshold
5139
+ })
5140
+ );
5141
+ } catch (error) {
5142
+ const message = error instanceof Error ? error.message : String(error);
5143
+ return c.error({
5144
+ code: "COMPARE_FAILED",
5145
+ message: `Unable to compare images: ${message}`,
5146
+ retryable: false
5147
+ });
5148
+ }
5149
+ }
5150
+ });
4430
5151
  var template = Cli.create("template", {
4431
5152
  description: "Generate common design templates and run the full render \u2192 QA pipeline."
4432
5153
  });
@@ -4668,7 +5389,8 @@ cli.command("qa", {
4668
5389
  options: z3.object({
4669
5390
  in: z3.string().describe("Path to rendered PNG"),
4670
5391
  spec: z3.string().describe("Path to normalized DesignSpec JSON"),
4671
- meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)")
5392
+ meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)"),
5393
+ reference: z3.string().optional().describe("Optional reference image path for visual comparison")
4672
5394
  }),
4673
5395
  output: z3.object({
4674
5396
  pass: z3.boolean(),
@@ -4682,7 +5404,18 @@ cli.command("qa", {
4682
5404
  message: z3.string(),
4683
5405
  elementId: z3.string().optional()
4684
5406
  })
4685
- )
5407
+ ),
5408
+ reference: z3.object({
5409
+ similarity: z3.number(),
5410
+ verdict: z3.enum(["match", "close", "mismatch"]),
5411
+ regions: z3.array(
5412
+ z3.object({
5413
+ label: z3.string(),
5414
+ similarity: z3.number(),
5415
+ description: z3.string().optional()
5416
+ })
5417
+ )
5418
+ }).optional()
4686
5419
  }),
4687
5420
  examples: [
4688
5421
  {
@@ -4705,14 +5438,16 @@ cli.command("qa", {
4705
5438
  const report = await runQa({
4706
5439
  imagePath: c.options.in,
4707
5440
  spec,
4708
- ...metadata ? { metadata } : {}
5441
+ ...metadata ? { metadata } : {},
5442
+ ...c.options.reference ? { referencePath: c.options.reference } : {}
4709
5443
  });
4710
5444
  const response = {
4711
5445
  pass: report.pass,
4712
5446
  checkedAt: report.checkedAt,
4713
5447
  imagePath: report.imagePath,
4714
5448
  issueCount: report.issues.length,
4715
- issues: report.issues
5449
+ issues: report.issues,
5450
+ ...report.reference ? { reference: report.reference } : {}
4716
5451
  };
4717
5452
  if (!report.pass) {
4718
5453
  return c.error({