@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/index.js CHANGED
@@ -1,4 +1,152 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/compare.ts
12
+ var compare_exports = {};
13
+ __export(compare_exports, {
14
+ compareImages: () => compareImages
15
+ });
16
+ import sharp from "sharp";
17
+ function clampUnit(value) {
18
+ if (value < 0) {
19
+ return 0;
20
+ }
21
+ if (value > 1) {
22
+ return 1;
23
+ }
24
+ return value;
25
+ }
26
+ function toRegionLabel(row, column) {
27
+ const letter = String.fromCharCode(65 + row);
28
+ return `${letter}${column + 1}`;
29
+ }
30
+ function validateGrid(grid) {
31
+ if (!Number.isInteger(grid) || grid <= 0) {
32
+ throw new Error(`Invalid grid value "${grid}". Expected a positive integer.`);
33
+ }
34
+ if (grid > 26) {
35
+ throw new Error(`Invalid grid value "${grid}". Maximum supported grid is 26.`);
36
+ }
37
+ return grid;
38
+ }
39
+ function validateThreshold(threshold) {
40
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
41
+ throw new Error(`Invalid threshold value "${threshold}". Expected a number between 0 and 1.`);
42
+ }
43
+ return threshold;
44
+ }
45
+ async function readDimensions(path) {
46
+ const metadata = await sharp(path).metadata();
47
+ if (!metadata.width || !metadata.height) {
48
+ throw new Error(`Unable to read image dimensions for "${path}".`);
49
+ }
50
+ return {
51
+ width: metadata.width,
52
+ height: metadata.height
53
+ };
54
+ }
55
+ async function normalizeToRaw(path, width, height) {
56
+ const normalized = await sharp(path).rotate().resize(width, height, {
57
+ fit: "contain",
58
+ position: "centre",
59
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
60
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
61
+ return {
62
+ data: normalized.data,
63
+ width: normalized.info.width,
64
+ height: normalized.info.height
65
+ };
66
+ }
67
+ function scorePixelDifference(a, b, offset) {
68
+ const redDiff = Math.abs(a.data[offset] - b.data[offset]);
69
+ const greenDiff = Math.abs(a.data[offset + 1] - b.data[offset + 1]);
70
+ const blueDiff = Math.abs(a.data[offset + 2] - b.data[offset + 2]);
71
+ const alphaDiff = Math.abs(a.data[offset + 3] - b.data[offset + 3]);
72
+ const rgbDelta = (redDiff + greenDiff + blueDiff) / (3 * 255);
73
+ const alphaDelta = alphaDiff / 255;
74
+ return rgbDelta * 0.75 + alphaDelta * 0.25;
75
+ }
76
+ async function compareImages(target, rendered, options = {}) {
77
+ const grid = validateGrid(options.grid ?? DEFAULT_GRID);
78
+ const threshold = validateThreshold(options.threshold ?? DEFAULT_THRESHOLD);
79
+ const closeThreshold = clampUnit(threshold - (options.closeMargin ?? DEFAULT_CLOSE_MARGIN));
80
+ const targetDimensions = await readDimensions(target);
81
+ const renderedDimensions = await readDimensions(rendered);
82
+ const normalizedWidth = Math.max(targetDimensions.width, renderedDimensions.width);
83
+ const normalizedHeight = Math.max(targetDimensions.height, renderedDimensions.height);
84
+ const [targetImage, renderedImage] = await Promise.all([
85
+ normalizeToRaw(target, normalizedWidth, normalizedHeight),
86
+ normalizeToRaw(rendered, normalizedWidth, normalizedHeight)
87
+ ]);
88
+ const regionDiffSums = new Array(grid * grid).fill(0);
89
+ const regionCounts = new Array(grid * grid).fill(0);
90
+ let totalDiff = 0;
91
+ for (let y = 0; y < normalizedHeight; y += 1) {
92
+ const row = Math.min(Math.floor(y * grid / normalizedHeight), grid - 1);
93
+ for (let x = 0; x < normalizedWidth; x += 1) {
94
+ const column = Math.min(Math.floor(x * grid / normalizedWidth), grid - 1);
95
+ const regionIndex = row * grid + column;
96
+ const offset = (y * normalizedWidth + x) * 4;
97
+ const diff = scorePixelDifference(targetImage, renderedImage, offset);
98
+ totalDiff += diff;
99
+ regionDiffSums[regionIndex] += diff;
100
+ regionCounts[regionIndex] += 1;
101
+ }
102
+ }
103
+ const pixelCount = normalizedWidth * normalizedHeight;
104
+ const similarity = clampUnit(1 - totalDiff / pixelCount);
105
+ const regions = [];
106
+ for (let row = 0; row < grid; row += 1) {
107
+ for (let column = 0; column < grid; column += 1) {
108
+ const regionIndex = row * grid + column;
109
+ const regionCount = regionCounts[regionIndex];
110
+ const regionSimilarity = regionCount > 0 ? clampUnit(1 - regionDiffSums[regionIndex] / regionCount) : 1;
111
+ regions.push({
112
+ label: toRegionLabel(row, column),
113
+ row,
114
+ column,
115
+ similarity: regionSimilarity
116
+ });
117
+ }
118
+ }
119
+ const verdict = similarity >= threshold ? "match" : similarity >= closeThreshold ? "close" : "mismatch";
120
+ return {
121
+ targetPath: target,
122
+ renderedPath: rendered,
123
+ targetDimensions,
124
+ renderedDimensions,
125
+ normalizedDimensions: {
126
+ width: normalizedWidth,
127
+ height: normalizedHeight
128
+ },
129
+ dimensionMismatch: targetDimensions.width !== renderedDimensions.width || targetDimensions.height !== renderedDimensions.height,
130
+ grid,
131
+ threshold,
132
+ closeThreshold,
133
+ similarity,
134
+ verdict,
135
+ regions
136
+ };
137
+ }
138
+ var DEFAULT_GRID, DEFAULT_THRESHOLD, DEFAULT_CLOSE_MARGIN;
139
+ var init_compare = __esm({
140
+ "src/compare.ts"() {
141
+ "use strict";
142
+ DEFAULT_GRID = 3;
143
+ DEFAULT_THRESHOLD = 0.8;
144
+ DEFAULT_CLOSE_MARGIN = 0.1;
145
+ }
146
+ });
147
+
1
148
  // src/cli.ts
149
+ init_compare();
2
150
  import { readFileSync, realpathSync } from "fs";
3
151
  import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
4
152
  import { basename as basename4, dirname as dirname3, resolve as resolve4 } from "path";
@@ -196,7 +344,7 @@ async function publishToGitHub(options) {
196
344
  // src/qa.ts
197
345
  import { readFile as readFile3 } from "fs/promises";
198
346
  import { resolve } from "path";
199
- import sharp from "sharp";
347
+ import sharp2 from "sharp";
200
348
 
201
349
  // src/code-style.ts
202
350
  var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
@@ -257,7 +405,110 @@ import { z as z2 } from "zod";
257
405
 
258
406
  // src/themes/builtin.ts
259
407
  import { z } from "zod";
260
- var colorHexSchema = z.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
408
+
409
+ // src/utils/color.ts
410
+ function parseChannel(hex, offset) {
411
+ return Number.parseInt(hex.slice(offset, offset + 2), 16);
412
+ }
413
+ function parseHexColor(hexColor) {
414
+ const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
415
+ if (normalized.length !== 6 && normalized.length !== 8) {
416
+ throw new Error(`Unsupported color format: ${hexColor}`);
417
+ }
418
+ return {
419
+ r: parseChannel(normalized, 0),
420
+ g: parseChannel(normalized, 2),
421
+ b: parseChannel(normalized, 4)
422
+ };
423
+ }
424
+ var rgbaRegex = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01](?:\.\d+)?|0?\.\d+)\s*)?\)$/;
425
+ var hexColorRegex = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
426
+ function toHex(n) {
427
+ return n.toString(16).padStart(2, "0");
428
+ }
429
+ function parseRgbaToHex(color) {
430
+ const match = rgbaRegex.exec(color);
431
+ if (!match) {
432
+ throw new Error(`Invalid rgb/rgba color: ${color}`);
433
+ }
434
+ const r = Number.parseInt(match[1], 10);
435
+ const g = Number.parseInt(match[2], 10);
436
+ const b = Number.parseInt(match[3], 10);
437
+ if (r > 255 || g > 255 || b > 255) {
438
+ throw new Error(`RGB channel values must be 0-255, got: ${color}`);
439
+ }
440
+ if (match[4] !== void 0) {
441
+ const a = Number.parseFloat(match[4]);
442
+ if (a < 0 || a > 1) {
443
+ throw new Error(`Alpha value must be 0-1, got: ${a}`);
444
+ }
445
+ const alphaByte = Math.round(a * 255);
446
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaByte)}`;
447
+ }
448
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
449
+ }
450
+ function isRgbaColor(color) {
451
+ return rgbaRegex.test(color);
452
+ }
453
+ function isHexColor(color) {
454
+ return hexColorRegex.test(color);
455
+ }
456
+ function normalizeColor(color) {
457
+ if (isHexColor(color)) {
458
+ return color;
459
+ }
460
+ if (isRgbaColor(color)) {
461
+ return parseRgbaToHex(color);
462
+ }
463
+ throw new Error(`Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color, got: ${color}`);
464
+ }
465
+ function srgbToLinear(channel) {
466
+ const normalized = channel / 255;
467
+ if (normalized <= 0.03928) {
468
+ return normalized / 12.92;
469
+ }
470
+ return ((normalized + 0.055) / 1.055) ** 2.4;
471
+ }
472
+ function relativeLuminance(hexColor) {
473
+ const normalized = isRgbaColor(hexColor) ? parseRgbaToHex(hexColor) : hexColor;
474
+ const rgb = parseHexColor(normalized);
475
+ const r = srgbToLinear(rgb.r);
476
+ const g = srgbToLinear(rgb.g);
477
+ const b = srgbToLinear(rgb.b);
478
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
479
+ }
480
+ function contrastRatio(foreground, background) {
481
+ const fg = relativeLuminance(foreground);
482
+ const bg = relativeLuminance(background);
483
+ const lighter = Math.max(fg, bg);
484
+ const darker = Math.min(fg, bg);
485
+ return (lighter + 0.05) / (darker + 0.05);
486
+ }
487
+ function withAlpha(hexColor, opacity) {
488
+ const rgb = parseHexColor(hexColor);
489
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
490
+ }
491
+ function blendColorWithOpacity(foreground, background, opacity) {
492
+ const fg = parseHexColor(foreground);
493
+ const bg = parseHexColor(background);
494
+ const r = Math.round(fg.r * opacity + bg.r * (1 - opacity));
495
+ const g = Math.round(fg.g * opacity + bg.g * (1 - opacity));
496
+ const b = Math.round(fg.b * opacity + bg.b * (1 - opacity));
497
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
498
+ }
499
+
500
+ // src/themes/builtin.ts
501
+ var colorHexSchema = z.string().refine(
502
+ (v) => {
503
+ try {
504
+ normalizeColor(v);
505
+ return true;
506
+ } catch {
507
+ return false;
508
+ }
509
+ },
510
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
511
+ ).transform((v) => normalizeColor(v));
261
512
  var fontFamilySchema = z.string().min(1).max(120);
262
513
  var codeThemeSchema = z.object({
263
514
  background: colorHexSchema,
@@ -488,7 +739,17 @@ function resolveTheme(theme) {
488
739
  }
489
740
 
490
741
  // src/spec.schema.ts
491
- var colorHexSchema2 = z2.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
742
+ var colorHexSchema2 = z2.string().refine(
743
+ (v) => {
744
+ try {
745
+ normalizeColor(v);
746
+ return true;
747
+ } catch {
748
+ return false;
749
+ }
750
+ },
751
+ { message: "Expected #RRGGBB, #RRGGBBAA, rgb(), or rgba() color" }
752
+ ).transform((v) => normalizeColor(v));
492
753
  var gradientStopSchema = z2.object({
493
754
  offset: z2.number().min(0).max(1),
494
755
  color: colorHexSchema2
@@ -673,13 +934,32 @@ var cardElementSchema = z2.object({
673
934
  tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
674
935
  icon: z2.string().min(1).max(64).optional()
675
936
  }).strict();
937
+ var flowNodeShadowSchema = z2.object({
938
+ color: colorHexSchema2.optional(),
939
+ blur: z2.number().min(0).max(64).default(8),
940
+ offsetX: z2.number().min(-32).max(32).default(0),
941
+ offsetY: z2.number().min(-32).max(32).default(0),
942
+ opacity: z2.number().min(0).max(1).default(0.3)
943
+ }).strict();
676
944
  var flowNodeElementSchema = z2.object({
677
945
  type: z2.literal("flow-node"),
678
946
  id: z2.string().min(1).max(120),
679
- shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
947
+ shape: z2.enum([
948
+ "box",
949
+ "rounded-box",
950
+ "diamond",
951
+ "circle",
952
+ "pill",
953
+ "cylinder",
954
+ "parallelogram",
955
+ "hexagon"
956
+ ]).default("rounded-box"),
680
957
  label: z2.string().min(1).max(200),
681
958
  sublabel: z2.string().min(1).max(300).optional(),
682
959
  sublabelColor: colorHexSchema2.optional(),
960
+ sublabel2: z2.string().min(1).max(300).optional(),
961
+ sublabel2Color: colorHexSchema2.optional(),
962
+ sublabel2FontSize: z2.number().min(8).max(32).optional(),
683
963
  labelColor: colorHexSchema2.optional(),
684
964
  labelFontSize: z2.number().min(10).max(48).optional(),
685
965
  color: colorHexSchema2.optional(),
@@ -688,20 +968,30 @@ var flowNodeElementSchema = z2.object({
688
968
  cornerRadius: z2.number().min(0).max(64).optional(),
689
969
  width: z2.number().int().min(40).max(800).optional(),
690
970
  height: z2.number().int().min(30).max(600).optional(),
691
- opacity: z2.number().min(0).max(1).default(1)
971
+ fillOpacity: z2.number().min(0).max(1).default(1),
972
+ opacity: z2.number().min(0).max(1).default(1),
973
+ badgeText: z2.string().min(1).max(32).optional(),
974
+ badgeColor: colorHexSchema2.optional(),
975
+ badgeBackground: colorHexSchema2.optional(),
976
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
977
+ shadow: flowNodeShadowSchema.optional()
692
978
  }).strict();
693
979
  var connectionElementSchema = z2.object({
694
980
  type: z2.literal("connection"),
695
981
  from: z2.string().min(1).max(120),
696
982
  to: z2.string().min(1).max(120),
697
983
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
984
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
698
985
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
699
986
  label: z2.string().min(1).max(200).optional(),
700
987
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
701
988
  color: colorHexSchema2.optional(),
702
- width: z2.number().min(0.5).max(8).optional(),
989
+ width: z2.number().min(0.5).max(10).optional(),
990
+ strokeWidth: z2.number().min(0.5).max(10).default(2),
703
991
  arrowSize: z2.number().min(4).max(32).optional(),
704
- opacity: z2.number().min(0).max(1).default(1)
992
+ opacity: z2.number().min(0).max(1).default(1),
993
+ routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
994
+ tension: z2.number().min(0.1).max(0.8).default(0.35)
705
995
  }).strict();
706
996
  var codeBlockStyleSchema = z2.object({
707
997
  paddingVertical: z2.number().min(0).max(128).default(56),
@@ -770,6 +1060,10 @@ var elementSchema = z2.discriminatedUnion("type", [
770
1060
  shapeElementSchema,
771
1061
  imageElementSchema
772
1062
  ]);
1063
+ var diagramCenterSchema = z2.object({
1064
+ x: z2.number(),
1065
+ y: z2.number()
1066
+ }).strict();
773
1067
  var autoLayoutConfigSchema = z2.object({
774
1068
  mode: z2.literal("auto"),
775
1069
  algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
@@ -777,7 +1071,17 @@ var autoLayoutConfigSchema = z2.object({
777
1071
  nodeSpacing: z2.number().int().min(0).max(512).default(80),
778
1072
  rankSpacing: z2.number().int().min(0).max(512).default(120),
779
1073
  edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
780
- aspectRatio: z2.number().min(0.5).max(3).optional()
1074
+ aspectRatio: z2.number().min(0.5).max(3).optional(),
1075
+ /** ID of the root node for radial layout. Only relevant when algorithm is 'radial'. */
1076
+ radialRoot: z2.string().min(1).max(120).optional(),
1077
+ /** Fixed radius in pixels for radial layout. Only relevant when algorithm is 'radial'. */
1078
+ radialRadius: z2.number().positive().optional(),
1079
+ /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
1080
+ radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
1081
+ /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
1082
+ radialSortBy: z2.enum(["id", "connections"]).optional(),
1083
+ /** Explicit center used by curve/arc connection routing. */
1084
+ diagramCenter: diagramCenterSchema.optional()
781
1085
  }).strict();
782
1086
  var gridLayoutConfigSchema = z2.object({
783
1087
  mode: z2.literal("grid"),
@@ -785,13 +1089,17 @@ var gridLayoutConfigSchema = z2.object({
785
1089
  gap: z2.number().int().min(0).max(256).default(24),
786
1090
  cardMinHeight: z2.number().int().min(32).max(4096).optional(),
787
1091
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
788
- equalHeight: z2.boolean().default(false)
1092
+ equalHeight: z2.boolean().default(false),
1093
+ /** Explicit center used by curve/arc connection routing. */
1094
+ diagramCenter: diagramCenterSchema.optional()
789
1095
  }).strict();
790
1096
  var stackLayoutConfigSchema = z2.object({
791
1097
  mode: z2.literal("stack"),
792
1098
  direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
793
1099
  gap: z2.number().int().min(0).max(256).default(24),
794
- alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
1100
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
1101
+ /** Explicit center used by curve/arc connection routing. */
1102
+ diagramCenter: diagramCenterSchema.optional()
795
1103
  }).strict();
796
1104
  var manualPositionSchema = z2.object({
797
1105
  x: z2.number().int(),
@@ -801,7 +1109,9 @@ var manualPositionSchema = z2.object({
801
1109
  }).strict();
802
1110
  var manualLayoutConfigSchema = z2.object({
803
1111
  mode: z2.literal("manual"),
804
- positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
1112
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
1113
+ /** Explicit center used by curve/arc connection routing. */
1114
+ diagramCenter: diagramCenterSchema.optional()
805
1115
  }).strict();
806
1116
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
807
1117
  autoLayoutConfigSchema,
@@ -853,6 +1163,31 @@ var canvasSchema = z2.object({
853
1163
  padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
854
1164
  }).strict();
855
1165
  var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
1166
+ var diagramPositionSchema = z2.object({
1167
+ x: z2.number(),
1168
+ y: z2.number(),
1169
+ width: z2.number().positive(),
1170
+ height: z2.number().positive()
1171
+ }).strict();
1172
+ var diagramElementSchema = z2.discriminatedUnion("type", [
1173
+ flowNodeElementSchema,
1174
+ connectionElementSchema
1175
+ ]);
1176
+ var diagramLayoutSchema = z2.object({
1177
+ mode: z2.enum(["manual", "auto"]).default("manual"),
1178
+ positions: z2.record(z2.string(), diagramPositionSchema).optional(),
1179
+ diagramCenter: diagramCenterSchema.optional()
1180
+ }).strict();
1181
+ var diagramSpecSchema = z2.object({
1182
+ version: z2.literal(1),
1183
+ canvas: z2.object({
1184
+ width: z2.number().int().min(320).max(4096).default(1200),
1185
+ height: z2.number().int().min(180).max(4096).default(675)
1186
+ }).default({ width: 1200, height: 675 }),
1187
+ theme: themeSchema.optional(),
1188
+ elements: z2.array(diagramElementSchema).min(1),
1189
+ layout: diagramLayoutSchema.default({ mode: "manual" })
1190
+ }).strict();
856
1191
  var designSpecSchema = z2.object({
857
1192
  version: z2.literal(2).default(2),
858
1193
  canvas: canvasSchema.default(defaultCanvas),
@@ -877,47 +1212,13 @@ function deriveSafeFrame(spec) {
877
1212
  height: spec.canvas.height - spec.canvas.padding * 2
878
1213
  };
879
1214
  }
1215
+ function parseDiagramSpec(input) {
1216
+ return diagramSpecSchema.parse(input);
1217
+ }
880
1218
  function parseDesignSpec(input) {
881
1219
  return designSpecSchema.parse(input);
882
1220
  }
883
1221
 
884
- // src/utils/color.ts
885
- function parseChannel(hex, offset) {
886
- return Number.parseInt(hex.slice(offset, offset + 2), 16);
887
- }
888
- function parseHexColor(hexColor) {
889
- const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
890
- if (normalized.length !== 6 && normalized.length !== 8) {
891
- throw new Error(`Unsupported color format: ${hexColor}`);
892
- }
893
- return {
894
- r: parseChannel(normalized, 0),
895
- g: parseChannel(normalized, 2),
896
- b: parseChannel(normalized, 4)
897
- };
898
- }
899
- function srgbToLinear(channel) {
900
- const normalized = channel / 255;
901
- if (normalized <= 0.03928) {
902
- return normalized / 12.92;
903
- }
904
- return ((normalized + 0.055) / 1.055) ** 2.4;
905
- }
906
- function relativeLuminance(hexColor) {
907
- const rgb = parseHexColor(hexColor);
908
- const r = srgbToLinear(rgb.r);
909
- const g = srgbToLinear(rgb.g);
910
- const b = srgbToLinear(rgb.b);
911
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
912
- }
913
- function contrastRatio(foreground, background) {
914
- const fg = relativeLuminance(foreground);
915
- const bg = relativeLuminance(background);
916
- const lighter = Math.max(fg, bg);
917
- const darker = Math.min(fg, bg);
918
- return (lighter + 0.05) / (darker + 0.05);
919
- }
920
-
921
1222
  // src/qa.ts
922
1223
  function rectWithin(outer, inner) {
923
1224
  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;
@@ -963,7 +1264,7 @@ async function runQa(options) {
963
1264
  const imagePath = resolve(options.imagePath);
964
1265
  const expectedSafeFrame = deriveSafeFrame(spec);
965
1266
  const expectedCanvas = canvasRect(spec);
966
- const imageMetadata = await sharp(imagePath).metadata();
1267
+ const imageMetadata = await sharp2(imagePath).metadata();
967
1268
  const issues = [];
968
1269
  const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
969
1270
  const expectedWidth = spec.canvas.width * expectedScale;
@@ -1114,6 +1415,31 @@ async function runQa(options) {
1114
1415
  });
1115
1416
  }
1116
1417
  }
1418
+ let referenceResult;
1419
+ if (options.referencePath) {
1420
+ const { compareImages: compareImages2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
1421
+ const comparison = await compareImages2(options.referencePath, imagePath);
1422
+ referenceResult = {
1423
+ similarity: comparison.similarity,
1424
+ verdict: comparison.verdict,
1425
+ regions: comparison.regions.map((region) => ({
1426
+ label: region.label,
1427
+ similarity: region.similarity
1428
+ }))
1429
+ };
1430
+ if (comparison.verdict === "mismatch") {
1431
+ const severity = comparison.similarity < 0.5 ? "error" : "warning";
1432
+ issues.push({
1433
+ code: "REFERENCE_MISMATCH",
1434
+ severity,
1435
+ message: `Reference image comparison ${severity === "error" ? "failed" : "warned"}: similarity ${comparison.similarity.toFixed(4)} with verdict "${comparison.verdict}".`,
1436
+ details: {
1437
+ similarity: comparison.similarity,
1438
+ verdict: comparison.verdict
1439
+ }
1440
+ });
1441
+ }
1442
+ }
1117
1443
  const footerSpacingPx = options.metadata?.layout.elements ? (() => {
1118
1444
  const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
1119
1445
  if (!footer) {
@@ -1146,7 +1472,8 @@ async function runQa(options) {
1146
1472
  ...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
1147
1473
  ...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
1148
1474
  },
1149
- issues
1475
+ issues,
1476
+ ...referenceResult ? { reference: referenceResult } : {}
1150
1477
  };
1151
1478
  }
1152
1479
 
@@ -1184,87 +1511,482 @@ function loadFonts() {
1184
1511
  // src/layout/elk.ts
1185
1512
  import ELK from "elkjs";
1186
1513
 
1187
- // src/layout/estimates.ts
1188
- function estimateElementHeight(element) {
1189
- switch (element.type) {
1190
- case "card":
1191
- return 220;
1192
- case "flow-node":
1193
- return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
1194
- case "code-block":
1195
- return 260;
1196
- case "terminal":
1197
- return 245;
1198
- case "text":
1199
- return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
1200
- case "shape":
1201
- return 130;
1202
- case "image":
1203
- return 220;
1204
- case "connection":
1205
- return 0;
1206
- }
1514
+ // src/primitives/shapes.ts
1515
+ function roundRectPath(ctx, rect, radius) {
1516
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1517
+ const right = rect.x + rect.width;
1518
+ const bottom = rect.y + rect.height;
1519
+ ctx.beginPath();
1520
+ ctx.moveTo(rect.x + r, rect.y);
1521
+ ctx.lineTo(right - r, rect.y);
1522
+ ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1523
+ ctx.lineTo(right, bottom - r);
1524
+ ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1525
+ ctx.lineTo(rect.x + r, bottom);
1526
+ ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1527
+ ctx.lineTo(rect.x, rect.y + r);
1528
+ ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1529
+ ctx.closePath();
1207
1530
  }
1208
- function estimateElementWidth(element) {
1209
- switch (element.type) {
1210
- case "card":
1211
- return 320;
1212
- case "flow-node":
1213
- return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
1214
- case "code-block":
1215
- return 420;
1216
- case "terminal":
1217
- return 420;
1218
- case "text":
1219
- return 360;
1220
- case "shape":
1221
- return 280;
1222
- case "image":
1223
- return 320;
1224
- case "connection":
1225
- return 0;
1531
+ function fillAndStroke(ctx, fill, stroke) {
1532
+ ctx.fillStyle = fill;
1533
+ ctx.fill();
1534
+ if (stroke) {
1535
+ ctx.strokeStyle = stroke;
1536
+ ctx.stroke();
1226
1537
  }
1227
1538
  }
1228
-
1229
- // src/layout/stack.ts
1230
- function computeStackLayout(elements, config, safeFrame) {
1231
- const placeable = elements.filter((element) => element.type !== "connection");
1232
- const positions = /* @__PURE__ */ new Map();
1233
- if (placeable.length === 0) {
1234
- return { positions };
1235
- }
1236
- const gap = config.gap;
1237
- if (config.direction === "vertical") {
1238
- const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
1239
- const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
1240
- const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
1241
- const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
1242
- let y = safeFrame.y;
1243
- for (const [index, element] of placeable.entries()) {
1244
- const stretched = config.alignment === "stretch";
1245
- const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
1246
- const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
1247
- let x2 = safeFrame.x;
1248
- if (!stretched) {
1249
- if (config.alignment === "center") {
1250
- x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
1251
- } else if (config.alignment === "end") {
1252
- x2 = safeFrame.x + safeFrame.width - width;
1253
- }
1254
- }
1255
- positions.set(element.id, { x: x2, y, width, height });
1256
- y += height + gap;
1257
- }
1258
- return { positions };
1259
- }
1260
- const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
1261
- const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
1262
- const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
1263
- const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
1264
- let x = safeFrame.x;
1265
- for (const [index, element] of placeable.entries()) {
1266
- const stretched = config.alignment === "stretch";
1267
- const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
1539
+ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1540
+ roundRectPath(ctx, rect, radius);
1541
+ fillAndStroke(ctx, fill, stroke);
1542
+ }
1543
+ function drawCircle(ctx, center, radius, fill, stroke) {
1544
+ ctx.beginPath();
1545
+ ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
1546
+ ctx.closePath();
1547
+ fillAndStroke(ctx, fill, stroke);
1548
+ }
1549
+ function drawDiamond(ctx, bounds, fill, stroke) {
1550
+ const cx = bounds.x + bounds.width / 2;
1551
+ const cy = bounds.y + bounds.height / 2;
1552
+ ctx.beginPath();
1553
+ ctx.moveTo(cx, bounds.y);
1554
+ ctx.lineTo(bounds.x + bounds.width, cy);
1555
+ ctx.lineTo(cx, bounds.y + bounds.height);
1556
+ ctx.lineTo(bounds.x, cy);
1557
+ ctx.closePath();
1558
+ fillAndStroke(ctx, fill, stroke);
1559
+ }
1560
+ function drawPill(ctx, bounds, fill, stroke) {
1561
+ drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1562
+ }
1563
+ function drawEllipse(ctx, bounds, fill, stroke) {
1564
+ const cx = bounds.x + bounds.width / 2;
1565
+ const cy = bounds.y + bounds.height / 2;
1566
+ ctx.beginPath();
1567
+ ctx.ellipse(
1568
+ cx,
1569
+ cy,
1570
+ Math.max(0, bounds.width / 2),
1571
+ Math.max(0, bounds.height / 2),
1572
+ 0,
1573
+ 0,
1574
+ Math.PI * 2
1575
+ );
1576
+ ctx.closePath();
1577
+ fillAndStroke(ctx, fill, stroke);
1578
+ }
1579
+ function drawCylinder(ctx, bounds, fill, stroke) {
1580
+ const rx = Math.max(2, bounds.width / 2);
1581
+ const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1582
+ const cx = bounds.x + bounds.width / 2;
1583
+ const topCy = bounds.y + ry;
1584
+ const bottomCy = bounds.y + bounds.height - ry;
1585
+ ctx.beginPath();
1586
+ ctx.moveTo(bounds.x, topCy);
1587
+ ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1588
+ ctx.lineTo(bounds.x + bounds.width, bottomCy);
1589
+ ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1590
+ ctx.closePath();
1591
+ fillAndStroke(ctx, fill, stroke);
1592
+ if (stroke) {
1593
+ ctx.beginPath();
1594
+ ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1595
+ ctx.closePath();
1596
+ ctx.strokeStyle = stroke;
1597
+ ctx.stroke();
1598
+ }
1599
+ }
1600
+ function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1601
+ const maxSkew = bounds.width * 0.45;
1602
+ const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1603
+ ctx.beginPath();
1604
+ ctx.moveTo(bounds.x + skewX, bounds.y);
1605
+ ctx.lineTo(bounds.x + bounds.width, bounds.y);
1606
+ ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1607
+ ctx.lineTo(bounds.x, bounds.y + bounds.height);
1608
+ ctx.closePath();
1609
+ fillAndStroke(ctx, fill, stroke);
1610
+ }
1611
+
1612
+ // src/primitives/text.ts
1613
+ var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1614
+ function resolveFont(requested, role) {
1615
+ if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1616
+ return requested;
1617
+ }
1618
+ if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1619
+ return "JetBrains Mono";
1620
+ }
1621
+ if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1622
+ return "Space Grotesk";
1623
+ }
1624
+ return "Inter";
1625
+ }
1626
+ function applyFont(ctx, options) {
1627
+ ctx.font = `${options.weight} ${options.size}px ${options.family}`;
1628
+ }
1629
+ function wrapText(ctx, text, maxWidth, maxLines) {
1630
+ const trimmed = text.trim();
1631
+ if (!trimmed) {
1632
+ return { lines: [], truncated: false };
1633
+ }
1634
+ const words = trimmed.split(/\s+/u);
1635
+ const lines = [];
1636
+ let current = "";
1637
+ for (const word of words) {
1638
+ const trial = current.length > 0 ? `${current} ${word}` : word;
1639
+ if (ctx.measureText(trial).width <= maxWidth) {
1640
+ current = trial;
1641
+ continue;
1642
+ }
1643
+ if (current.length > 0) {
1644
+ lines.push(current);
1645
+ current = word;
1646
+ } else {
1647
+ lines.push(word);
1648
+ current = "";
1649
+ }
1650
+ if (lines.length >= maxLines) {
1651
+ break;
1652
+ }
1653
+ }
1654
+ if (lines.length < maxLines && current.length > 0) {
1655
+ lines.push(current);
1656
+ }
1657
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
1658
+ if (!wasTruncated) {
1659
+ return { lines, truncated: false };
1660
+ }
1661
+ const lastIndex = lines.length - 1;
1662
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
1663
+ while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
1664
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
1665
+ }
1666
+ lines[lastIndex] = truncatedLine;
1667
+ return { lines, truncated: true };
1668
+ }
1669
+ function drawTextBlock(ctx, options) {
1670
+ applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
1671
+ const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
1672
+ ctx.fillStyle = options.color;
1673
+ for (const [index, line] of wrapped.lines.entries()) {
1674
+ ctx.fillText(line, options.x, options.y + index * options.lineHeight);
1675
+ }
1676
+ return {
1677
+ height: wrapped.lines.length * options.lineHeight,
1678
+ truncated: wrapped.truncated
1679
+ };
1680
+ }
1681
+ function drawTextLabel(ctx, text, position, options) {
1682
+ applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
1683
+ const textWidth = Math.ceil(ctx.measureText(text).width);
1684
+ const rect = {
1685
+ x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
1686
+ y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
1687
+ width: textWidth + options.padding * 2,
1688
+ height: options.fontSize + options.padding * 2
1689
+ };
1690
+ drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
1691
+ ctx.fillStyle = options.color;
1692
+ ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
1693
+ return rect;
1694
+ }
1695
+
1696
+ // src/renderers/flow-node.ts
1697
+ var BADGE_FONT_SIZE = 10;
1698
+ var BADGE_FONT_WEIGHT = 600;
1699
+ var BADGE_LETTER_SPACING = 1;
1700
+ var BADGE_PADDING_X = 8;
1701
+ var BADGE_PADDING_Y = 3;
1702
+ var BADGE_BORDER_RADIUS = 12;
1703
+ var BADGE_DEFAULT_COLOR = "#FFFFFF";
1704
+ var BADGE_PILL_HEIGHT = BADGE_FONT_SIZE + BADGE_PADDING_Y * 2;
1705
+ var BADGE_INSIDE_TOP_EXTRA = BADGE_PILL_HEIGHT + 6;
1706
+ function drawNodeShape(ctx, shape, bounds, fill, stroke, cornerRadius) {
1707
+ switch (shape) {
1708
+ case "box":
1709
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
1710
+ break;
1711
+ case "rounded-box":
1712
+ drawRoundedRect(ctx, bounds, cornerRadius, fill, stroke);
1713
+ break;
1714
+ case "diamond":
1715
+ drawDiamond(ctx, bounds, fill, stroke);
1716
+ break;
1717
+ case "circle": {
1718
+ const radius = Math.min(bounds.width, bounds.height) / 2;
1719
+ drawCircle(
1720
+ ctx,
1721
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
1722
+ radius,
1723
+ fill,
1724
+ stroke
1725
+ );
1726
+ break;
1727
+ }
1728
+ case "pill":
1729
+ drawPill(ctx, bounds, fill, stroke);
1730
+ break;
1731
+ case "cylinder":
1732
+ drawCylinder(ctx, bounds, fill, stroke);
1733
+ break;
1734
+ case "parallelogram":
1735
+ drawParallelogram(ctx, bounds, fill, stroke);
1736
+ break;
1737
+ }
1738
+ }
1739
+ function measureSpacedText(ctx, text, letterSpacing) {
1740
+ const base = ctx.measureText(text).width;
1741
+ const extraChars = [...text].length - 1;
1742
+ return extraChars > 0 ? base + extraChars * letterSpacing : base;
1743
+ }
1744
+ function drawSpacedText(ctx, text, centerX, centerY, letterSpacing) {
1745
+ const chars = [...text];
1746
+ if (chars.length === 0) return;
1747
+ const totalWidth = measureSpacedText(ctx, text, letterSpacing);
1748
+ let cursorX = centerX - totalWidth / 2;
1749
+ ctx.textAlign = "left";
1750
+ for (let i = 0; i < chars.length; i++) {
1751
+ ctx.fillText(chars[i], cursorX, centerY);
1752
+ cursorX += ctx.measureText(chars[i]).width + (i < chars.length - 1 ? letterSpacing : 0);
1753
+ }
1754
+ }
1755
+ function renderBadgePill(ctx, centerX, centerY, text, textColor, background, monoFont) {
1756
+ ctx.save();
1757
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1758
+ const textWidth = measureSpacedText(ctx, text, BADGE_LETTER_SPACING);
1759
+ const pillWidth = textWidth + BADGE_PADDING_X * 2;
1760
+ const pillHeight = BADGE_PILL_HEIGHT;
1761
+ const pillX = centerX - pillWidth / 2;
1762
+ const pillY = centerY - pillHeight / 2;
1763
+ ctx.fillStyle = background;
1764
+ ctx.beginPath();
1765
+ ctx.roundRect(pillX, pillY, pillWidth, pillHeight, BADGE_BORDER_RADIUS);
1766
+ ctx.fill();
1767
+ ctx.fillStyle = textColor;
1768
+ ctx.textBaseline = "middle";
1769
+ applyFont(ctx, { size: BADGE_FONT_SIZE, weight: BADGE_FONT_WEIGHT, family: monoFont });
1770
+ drawSpacedText(ctx, text, centerX, centerY, BADGE_LETTER_SPACING);
1771
+ ctx.restore();
1772
+ return pillWidth;
1773
+ }
1774
+ function renderFlowNode(ctx, node, bounds, theme) {
1775
+ const fillColor = node.color ?? theme.surfaceElevated;
1776
+ const borderColor = node.borderColor ?? theme.border;
1777
+ const borderWidth = node.borderWidth ?? 2;
1778
+ const cornerRadius = node.cornerRadius ?? 16;
1779
+ const labelColor = node.labelColor ?? theme.text;
1780
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
1781
+ const labelFontSize = node.labelFontSize ?? 20;
1782
+ const fillOpacity = node.fillOpacity ?? 1;
1783
+ const hasBadge = !!node.badgeText;
1784
+ const badgePosition = node.badgePosition ?? "inside-top";
1785
+ const badgeColor = node.badgeColor ?? BADGE_DEFAULT_COLOR;
1786
+ const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
1787
+ ctx.save();
1788
+ ctx.lineWidth = borderWidth;
1789
+ if (node.shadow) {
1790
+ const shadowColor = node.shadow.color ?? borderColor ?? theme.accent;
1791
+ ctx.shadowColor = withAlpha(shadowColor, node.shadow.opacity);
1792
+ ctx.shadowBlur = node.shadow.blur;
1793
+ ctx.shadowOffsetX = node.shadow.offsetX;
1794
+ ctx.shadowOffsetY = node.shadow.offsetY;
1795
+ }
1796
+ if (fillOpacity < 1) {
1797
+ ctx.globalAlpha = node.opacity * fillOpacity;
1798
+ drawNodeShape(ctx, node.shape, bounds, fillColor, void 0, cornerRadius);
1799
+ if (node.shadow) {
1800
+ ctx.shadowColor = "transparent";
1801
+ ctx.shadowBlur = 0;
1802
+ ctx.shadowOffsetX = 0;
1803
+ ctx.shadowOffsetY = 0;
1804
+ }
1805
+ ctx.globalAlpha = node.opacity;
1806
+ drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
1807
+ } else {
1808
+ ctx.globalAlpha = node.opacity;
1809
+ drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
1810
+ }
1811
+ if (node.shadow) {
1812
+ ctx.shadowColor = "transparent";
1813
+ ctx.shadowBlur = 0;
1814
+ ctx.shadowOffsetX = 0;
1815
+ ctx.shadowOffsetY = 0;
1816
+ }
1817
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
1818
+ const bodyFont = resolveFont(theme.fonts.body, "body");
1819
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
1820
+ const centerX = bounds.x + bounds.width / 2;
1821
+ const centerY = bounds.y + bounds.height / 2;
1822
+ const insideTopShift = hasBadge && badgePosition === "inside-top" ? BADGE_INSIDE_TOP_EXTRA / 2 : 0;
1823
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
1824
+ const sublabel2FontSize = node.sublabel2FontSize ?? 11;
1825
+ const sublabel2Color = node.sublabel2Color ?? sublabelColor;
1826
+ const lineCount = node.sublabel2 ? 3 : node.sublabel ? 2 : 1;
1827
+ const labelToSublabelGap = Math.max(20, sublabelFontSize + 6);
1828
+ const sublabelToSublabel2Gap = sublabel2FontSize + 4;
1829
+ let textBlockHeight;
1830
+ if (lineCount === 1) {
1831
+ textBlockHeight = labelFontSize;
1832
+ } else if (lineCount === 2) {
1833
+ textBlockHeight = labelFontSize + labelToSublabelGap;
1834
+ } else {
1835
+ textBlockHeight = labelFontSize + labelToSublabelGap + sublabelToSublabel2Gap;
1836
+ }
1837
+ const labelY = lineCount === 1 ? centerY + labelFontSize * 0.3 + insideTopShift : centerY - textBlockHeight / 2 + labelFontSize * 0.8 + insideTopShift;
1838
+ ctx.textAlign = "center";
1839
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
1840
+ ctx.fillStyle = labelColor;
1841
+ ctx.fillText(node.label, centerX, labelY);
1842
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
1843
+ let textBoundsHeight = 36;
1844
+ if (node.sublabel) {
1845
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
1846
+ ctx.fillStyle = sublabelColor;
1847
+ ctx.fillText(node.sublabel, centerX, labelY + labelToSublabelGap);
1848
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
1849
+ textBoundsHeight = 56;
1850
+ }
1851
+ if (node.sublabel2) {
1852
+ applyFont(ctx, { size: sublabel2FontSize, weight: 500, family: bodyFont });
1853
+ ctx.fillStyle = sublabel2Color;
1854
+ const sublabel2Y = node.sublabel ? labelY + labelToSublabelGap + sublabelToSublabel2Gap : labelY + labelToSublabelGap;
1855
+ ctx.fillText(node.sublabel2, centerX, sublabel2Y);
1856
+ textBoundsY = bounds.y + bounds.height / 2 - 30;
1857
+ textBoundsHeight = 72;
1858
+ }
1859
+ if (hasBadge && node.badgeText) {
1860
+ if (badgePosition === "inside-top") {
1861
+ const badgeCenterY = bounds.y + BADGE_PILL_HEIGHT / 2 + 8;
1862
+ renderBadgePill(
1863
+ ctx,
1864
+ centerX,
1865
+ badgeCenterY,
1866
+ node.badgeText,
1867
+ badgeColor,
1868
+ badgeBackground,
1869
+ monoFont
1870
+ );
1871
+ } else {
1872
+ const badgeCenterY = bounds.y - BADGE_PILL_HEIGHT / 2 - 4;
1873
+ renderBadgePill(
1874
+ ctx,
1875
+ centerX,
1876
+ badgeCenterY,
1877
+ node.badgeText,
1878
+ badgeColor,
1879
+ badgeBackground,
1880
+ monoFont
1881
+ );
1882
+ }
1883
+ }
1884
+ ctx.restore();
1885
+ const effectiveBg = fillOpacity < 1 ? blendColorWithOpacity(fillColor, theme.background, fillOpacity) : fillColor;
1886
+ return [
1887
+ {
1888
+ id: `flow-node-${node.id}`,
1889
+ kind: "flow-node",
1890
+ bounds,
1891
+ foregroundColor: labelColor,
1892
+ backgroundColor: effectiveBg
1893
+ },
1894
+ {
1895
+ id: `flow-node-${node.id}-label`,
1896
+ kind: "text",
1897
+ bounds: {
1898
+ x: bounds.x + 8,
1899
+ y: textBoundsY,
1900
+ width: bounds.width - 16,
1901
+ height: textBoundsHeight
1902
+ },
1903
+ foregroundColor: labelColor,
1904
+ backgroundColor: effectiveBg
1905
+ }
1906
+ ];
1907
+ }
1908
+
1909
+ // src/layout/estimates.ts
1910
+ function estimateElementHeight(element) {
1911
+ switch (element.type) {
1912
+ case "card":
1913
+ return 220;
1914
+ case "flow-node":
1915
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
1916
+ case "code-block":
1917
+ return 260;
1918
+ case "terminal":
1919
+ return 245;
1920
+ case "text":
1921
+ return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
1922
+ case "shape":
1923
+ return 130;
1924
+ case "image":
1925
+ return 220;
1926
+ case "connection":
1927
+ return 0;
1928
+ }
1929
+ }
1930
+ function estimateElementWidth(element) {
1931
+ switch (element.type) {
1932
+ case "card":
1933
+ return 320;
1934
+ case "flow-node":
1935
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
1936
+ case "code-block":
1937
+ return 420;
1938
+ case "terminal":
1939
+ return 420;
1940
+ case "text":
1941
+ return 360;
1942
+ case "shape":
1943
+ return 280;
1944
+ case "image":
1945
+ return 320;
1946
+ case "connection":
1947
+ return 0;
1948
+ }
1949
+ }
1950
+
1951
+ // src/layout/stack.ts
1952
+ function computeStackLayout(elements, config, safeFrame) {
1953
+ const placeable = elements.filter((element) => element.type !== "connection");
1954
+ const positions = /* @__PURE__ */ new Map();
1955
+ if (placeable.length === 0) {
1956
+ return { positions };
1957
+ }
1958
+ const gap = config.gap;
1959
+ if (config.direction === "vertical") {
1960
+ const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
1961
+ const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
1962
+ const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
1963
+ const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
1964
+ let y = safeFrame.y;
1965
+ for (const [index, element] of placeable.entries()) {
1966
+ const stretched = config.alignment === "stretch";
1967
+ const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
1968
+ const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
1969
+ let x2 = safeFrame.x;
1970
+ if (!stretched) {
1971
+ if (config.alignment === "center") {
1972
+ x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
1973
+ } else if (config.alignment === "end") {
1974
+ x2 = safeFrame.x + safeFrame.width - width;
1975
+ }
1976
+ }
1977
+ positions.set(element.id, { x: x2, y, width, height });
1978
+ y += height + gap;
1979
+ }
1980
+ return { positions };
1981
+ }
1982
+ const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
1983
+ const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
1984
+ const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
1985
+ const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
1986
+ let x = safeFrame.x;
1987
+ for (const [index, element] of placeable.entries()) {
1988
+ const stretched = config.alignment === "stretch";
1989
+ const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
1268
1990
  const width = Math.max(64, Math.floor(estimatedWidths[index] * scale));
1269
1991
  let y = safeFrame.y;
1270
1992
  if (!stretched) {
@@ -1282,33 +2004,37 @@ function computeStackLayout(elements, config, safeFrame) {
1282
2004
 
1283
2005
  // src/layout/elk.ts
1284
2006
  function estimateFlowNodeSize(node) {
2007
+ const badgeExtra = node.badgeText && (node.badgePosition ?? "inside-top") === "inside-top" ? BADGE_INSIDE_TOP_EXTRA : 0;
2008
+ const sublabel2Extra = node.sublabel2 ? (node.sublabel2FontSize ?? 11) + 4 : 0;
2009
+ const extra = badgeExtra + sublabel2Extra;
1285
2010
  if (node.width && node.height) {
1286
- return { width: node.width, height: node.height };
2011
+ return { width: node.width, height: node.height + extra };
1287
2012
  }
1288
2013
  if (node.width) {
2014
+ const baseHeight = node.shape === "diamond" || node.shape === "circle" ? node.width : 60;
1289
2015
  return {
1290
2016
  width: node.width,
1291
- height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
2017
+ height: baseHeight + extra
1292
2018
  };
1293
2019
  }
1294
2020
  if (node.height) {
1295
2021
  return {
1296
2022
  width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
1297
- height: node.height
2023
+ height: node.height + extra
1298
2024
  };
1299
2025
  }
1300
2026
  switch (node.shape) {
1301
2027
  case "diamond":
1302
2028
  case "circle":
1303
- return { width: 100, height: 100 };
2029
+ return { width: 100 + extra, height: 100 + extra };
1304
2030
  case "pill":
1305
- return { width: 180, height: 56 };
2031
+ return { width: 180, height: 56 + extra };
1306
2032
  case "cylinder":
1307
- return { width: 140, height: 92 };
2033
+ return { width: 140, height: 92 + extra };
1308
2034
  case "parallelogram":
1309
- return { width: 180, height: 72 };
2035
+ return { width: 180, height: 72 + extra };
1310
2036
  default:
1311
- return { width: 170, height: 64 };
2037
+ return { width: 170, height: 64 + extra };
1312
2038
  }
1313
2039
  }
1314
2040
  function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
@@ -1426,6 +2152,40 @@ function directionToElk(direction) {
1426
2152
  return "DOWN";
1427
2153
  }
1428
2154
  }
2155
+ function radialCompactionToElk(compaction) {
2156
+ switch (compaction) {
2157
+ case "radial":
2158
+ return "RADIAL_COMPACTION";
2159
+ case "wedge":
2160
+ return "WEDGE_COMPACTION";
2161
+ default:
2162
+ return "NONE";
2163
+ }
2164
+ }
2165
+ function radialSortByToElk(sortBy) {
2166
+ switch (sortBy) {
2167
+ case "connections":
2168
+ return "POLAR_COORDINATE";
2169
+ default:
2170
+ return "ID";
2171
+ }
2172
+ }
2173
+ function buildRadialOptions(config) {
2174
+ const options = {};
2175
+ if (config.radialRoot) {
2176
+ options["elk.radial.centerOnRoot"] = "true";
2177
+ }
2178
+ if (config.radialRadius != null) {
2179
+ options["elk.radial.radius"] = String(config.radialRadius);
2180
+ }
2181
+ if (config.radialCompaction) {
2182
+ options["elk.radial.compaction.strategy"] = radialCompactionToElk(config.radialCompaction);
2183
+ }
2184
+ if (config.radialSortBy) {
2185
+ options["elk.radial.orderId"] = radialSortByToElk(config.radialSortBy);
2186
+ }
2187
+ return options;
2188
+ }
1429
2189
  function fallbackForNoFlowNodes(nonFlow, safeFrame) {
1430
2190
  const fallbackConfig = {
1431
2191
  mode: "stack",
@@ -1461,6 +2221,11 @@ async function computeElkLayout(elements, config, safeFrame) {
1461
2221
  elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
1462
2222
  }
1463
2223
  const edgeIdToRouteKey = /* @__PURE__ */ new Map();
2224
+ const radialOptions = config.algorithm === "radial" ? buildRadialOptions(config) : {};
2225
+ const orderedFlowNodes = config.radialRoot && config.algorithm === "radial" ? [
2226
+ ...flowNodes.filter((node) => node.id === config.radialRoot),
2227
+ ...flowNodes.filter((node) => node.id !== config.radialRoot)
2228
+ ] : flowNodes;
1464
2229
  const elkGraph = {
1465
2230
  id: "root",
1466
2231
  layoutOptions: {
@@ -1470,9 +2235,10 @@ async function computeElkLayout(elements, config, safeFrame) {
1470
2235
  "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
1471
2236
  "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
1472
2237
  ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
1473
- ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
2238
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {},
2239
+ ...radialOptions
1474
2240
  },
1475
- children: flowNodes.map((node) => {
2241
+ children: orderedFlowNodes.map((node) => {
1476
2242
  const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
1477
2243
  return {
1478
2244
  id: node.id,
@@ -1712,259 +2478,77 @@ function parseHexColor2(color) {
1712
2478
  throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
1713
2479
  }
1714
2480
  const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
1715
- return {
1716
- r: parseChannel2(0),
1717
- g: parseChannel2(2),
1718
- b: parseChannel2(4),
1719
- a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
1720
- };
1721
- }
1722
- function withAlpha(color, alpha) {
1723
- const parsed = parseHexColor2(color);
1724
- const effectiveAlpha = clamp01(parsed.a * alpha);
1725
- return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
1726
- }
1727
- function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
1728
- const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
1729
- rect.x + rect.width / 2,
1730
- rect.y + rect.height / 2,
1731
- 0,
1732
- rect.x + rect.width / 2,
1733
- rect.y + rect.height / 2,
1734
- Math.max(rect.width, rect.height) / 2
1735
- );
1736
- addGradientStops(fill, gradient.stops);
1737
- ctx.save();
1738
- ctx.fillStyle = fill;
1739
- if (borderRadius > 0) {
1740
- roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
1741
- ctx.fill();
1742
- } else {
1743
- ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
1744
- }
1745
- ctx.restore();
1746
- }
1747
- function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
1748
- if (width <= 0 || thickness <= 0) {
1749
- return;
1750
- }
1751
- const gradient = ctx.createLinearGradient(x, y, x + width, y);
1752
- const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
1753
- for (const [index, color] of stops.entries()) {
1754
- gradient.addColorStop(index / (stops.length - 1), color);
1755
- }
1756
- const ruleTop = y - thickness / 2;
1757
- ctx.save();
1758
- roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
1759
- ctx.fillStyle = gradient;
1760
- ctx.fill();
1761
- ctx.restore();
1762
- }
1763
- function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
1764
- if (width <= 0 || height <= 0 || intensity <= 0) {
1765
- return;
1766
- }
1767
- const centerX = width / 2;
1768
- const centerY = height / 2;
1769
- const outerRadius = Math.max(width, height) / 2;
1770
- const innerRadius = Math.min(width, height) * 0.2;
1771
- const vignette = ctx.createRadialGradient(
1772
- centerX,
1773
- centerY,
1774
- innerRadius,
1775
- centerX,
1776
- centerY,
1777
- outerRadius
1778
- );
1779
- vignette.addColorStop(0, withAlpha(color, 0));
1780
- vignette.addColorStop(0.6, withAlpha(color, 0));
1781
- vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
1782
- ctx.save();
1783
- ctx.fillStyle = vignette;
1784
- ctx.fillRect(0, 0, width, height);
1785
- ctx.restore();
1786
- }
1787
-
1788
- // src/primitives/shapes.ts
1789
- function roundRectPath(ctx, rect, radius) {
1790
- const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1791
- const right = rect.x + rect.width;
1792
- const bottom = rect.y + rect.height;
1793
- ctx.beginPath();
1794
- ctx.moveTo(rect.x + r, rect.y);
1795
- ctx.lineTo(right - r, rect.y);
1796
- ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
1797
- ctx.lineTo(right, bottom - r);
1798
- ctx.quadraticCurveTo(right, bottom, right - r, bottom);
1799
- ctx.lineTo(rect.x + r, bottom);
1800
- ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
1801
- ctx.lineTo(rect.x, rect.y + r);
1802
- ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
1803
- ctx.closePath();
1804
- }
1805
- function fillAndStroke(ctx, fill, stroke) {
1806
- ctx.fillStyle = fill;
1807
- ctx.fill();
1808
- if (stroke) {
1809
- ctx.strokeStyle = stroke;
1810
- ctx.stroke();
1811
- }
1812
- }
1813
- function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1814
- roundRectPath(ctx, rect, radius);
1815
- fillAndStroke(ctx, fill, stroke);
1816
- }
1817
- function drawCircle(ctx, center2, radius, fill, stroke) {
1818
- ctx.beginPath();
1819
- ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1820
- ctx.closePath();
1821
- fillAndStroke(ctx, fill, stroke);
1822
- }
1823
- function drawDiamond(ctx, bounds, fill, stroke) {
1824
- const cx = bounds.x + bounds.width / 2;
1825
- const cy = bounds.y + bounds.height / 2;
1826
- ctx.beginPath();
1827
- ctx.moveTo(cx, bounds.y);
1828
- ctx.lineTo(bounds.x + bounds.width, cy);
1829
- ctx.lineTo(cx, bounds.y + bounds.height);
1830
- ctx.lineTo(bounds.x, cy);
1831
- ctx.closePath();
1832
- fillAndStroke(ctx, fill, stroke);
1833
- }
1834
- function drawPill(ctx, bounds, fill, stroke) {
1835
- drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
1836
- }
1837
- function drawEllipse(ctx, bounds, fill, stroke) {
1838
- const cx = bounds.x + bounds.width / 2;
1839
- const cy = bounds.y + bounds.height / 2;
1840
- ctx.beginPath();
1841
- ctx.ellipse(
1842
- cx,
1843
- cy,
1844
- Math.max(0, bounds.width / 2),
1845
- Math.max(0, bounds.height / 2),
1846
- 0,
1847
- 0,
1848
- Math.PI * 2
1849
- );
1850
- ctx.closePath();
1851
- fillAndStroke(ctx, fill, stroke);
1852
- }
1853
- function drawCylinder(ctx, bounds, fill, stroke) {
1854
- const rx = Math.max(2, bounds.width / 2);
1855
- const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
1856
- const cx = bounds.x + bounds.width / 2;
1857
- const topCy = bounds.y + ry;
1858
- const bottomCy = bounds.y + bounds.height - ry;
1859
- ctx.beginPath();
1860
- ctx.moveTo(bounds.x, topCy);
1861
- ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
1862
- ctx.lineTo(bounds.x + bounds.width, bottomCy);
1863
- ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
1864
- ctx.closePath();
1865
- fillAndStroke(ctx, fill, stroke);
1866
- if (stroke) {
1867
- ctx.beginPath();
1868
- ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
1869
- ctx.closePath();
1870
- ctx.strokeStyle = stroke;
1871
- ctx.stroke();
1872
- }
1873
- }
1874
- function drawParallelogram(ctx, bounds, fill, stroke, skew) {
1875
- const maxSkew = bounds.width * 0.45;
1876
- const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
1877
- ctx.beginPath();
1878
- ctx.moveTo(bounds.x + skewX, bounds.y);
1879
- ctx.lineTo(bounds.x + bounds.width, bounds.y);
1880
- ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
1881
- ctx.lineTo(bounds.x, bounds.y + bounds.height);
1882
- ctx.closePath();
1883
- fillAndStroke(ctx, fill, stroke);
1884
- }
1885
-
1886
- // src/primitives/text.ts
1887
- var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
1888
- function resolveFont(requested, role) {
1889
- if (SUPPORTED_FONT_FAMILIES.has(requested)) {
1890
- return requested;
1891
- }
1892
- if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
1893
- return "JetBrains Mono";
1894
- }
1895
- if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
1896
- return "Space Grotesk";
1897
- }
1898
- return "Inter";
2481
+ return {
2482
+ r: parseChannel2(0),
2483
+ g: parseChannel2(2),
2484
+ b: parseChannel2(4),
2485
+ a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
2486
+ };
1899
2487
  }
1900
- function applyFont(ctx, options) {
1901
- ctx.font = `${options.weight} ${options.size}px ${options.family}`;
2488
+ function withAlpha2(color, alpha) {
2489
+ const parsed = parseHexColor2(color);
2490
+ const effectiveAlpha = clamp01(parsed.a * alpha);
2491
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
1902
2492
  }
1903
- function wrapText(ctx, text, maxWidth, maxLines) {
1904
- const trimmed = text.trim();
1905
- if (!trimmed) {
1906
- return { lines: [], truncated: false };
1907
- }
1908
- const words = trimmed.split(/\s+/u);
1909
- const lines = [];
1910
- let current = "";
1911
- for (const word of words) {
1912
- const trial = current.length > 0 ? `${current} ${word}` : word;
1913
- if (ctx.measureText(trial).width <= maxWidth) {
1914
- current = trial;
1915
- continue;
1916
- }
1917
- if (current.length > 0) {
1918
- lines.push(current);
1919
- current = word;
1920
- } else {
1921
- lines.push(word);
1922
- current = "";
1923
- }
1924
- if (lines.length >= maxLines) {
1925
- break;
1926
- }
1927
- }
1928
- if (lines.length < maxLines && current.length > 0) {
1929
- lines.push(current);
2493
+ function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
2494
+ const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
2495
+ rect.x + rect.width / 2,
2496
+ rect.y + rect.height / 2,
2497
+ 0,
2498
+ rect.x + rect.width / 2,
2499
+ rect.y + rect.height / 2,
2500
+ Math.max(rect.width, rect.height) / 2
2501
+ );
2502
+ addGradientStops(fill, gradient.stops);
2503
+ ctx.save();
2504
+ ctx.fillStyle = fill;
2505
+ if (borderRadius > 0) {
2506
+ roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
2507
+ ctx.fill();
2508
+ } else {
2509
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
1930
2510
  }
1931
- const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
1932
- if (!wasTruncated) {
1933
- return { lines, truncated: false };
2511
+ ctx.restore();
2512
+ }
2513
+ function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
2514
+ if (width <= 0 || thickness <= 0) {
2515
+ return;
1934
2516
  }
1935
- const lastIndex = lines.length - 1;
1936
- let truncatedLine = `${lines[lastIndex]}\u2026`;
1937
- while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
1938
- truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
2517
+ const gradient = ctx.createLinearGradient(x, y, x + width, y);
2518
+ const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
2519
+ for (const [index, color] of stops.entries()) {
2520
+ gradient.addColorStop(index / (stops.length - 1), color);
1939
2521
  }
1940
- lines[lastIndex] = truncatedLine;
1941
- return { lines, truncated: true };
2522
+ const ruleTop = y - thickness / 2;
2523
+ ctx.save();
2524
+ roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
2525
+ ctx.fillStyle = gradient;
2526
+ ctx.fill();
2527
+ ctx.restore();
1942
2528
  }
1943
- function drawTextBlock(ctx, options) {
1944
- applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
1945
- const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
1946
- ctx.fillStyle = options.color;
1947
- for (const [index, line] of wrapped.lines.entries()) {
1948
- ctx.fillText(line, options.x, options.y + index * options.lineHeight);
2529
+ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
2530
+ if (width <= 0 || height <= 0 || intensity <= 0) {
2531
+ return;
1949
2532
  }
1950
- return {
1951
- height: wrapped.lines.length * options.lineHeight,
1952
- truncated: wrapped.truncated
1953
- };
1954
- }
1955
- function drawTextLabel(ctx, text, position, options) {
1956
- applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
1957
- const textWidth = Math.ceil(ctx.measureText(text).width);
1958
- const rect = {
1959
- x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
1960
- y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
1961
- width: textWidth + options.padding * 2,
1962
- height: options.fontSize + options.padding * 2
1963
- };
1964
- drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
1965
- ctx.fillStyle = options.color;
1966
- ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
1967
- return rect;
2533
+ const centerX = width / 2;
2534
+ const centerY = height / 2;
2535
+ const outerRadius = Math.max(width, height) / 2;
2536
+ const innerRadius = Math.min(width, height) * 0.2;
2537
+ const vignette = ctx.createRadialGradient(
2538
+ centerX,
2539
+ centerY,
2540
+ innerRadius,
2541
+ centerX,
2542
+ centerY,
2543
+ outerRadius
2544
+ );
2545
+ vignette.addColorStop(0, withAlpha2(color, 0));
2546
+ vignette.addColorStop(0.6, withAlpha2(color, 0));
2547
+ vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
2548
+ ctx.save();
2549
+ ctx.fillStyle = vignette;
2550
+ ctx.fillRect(0, 0, width, height);
2551
+ ctx.restore();
1968
2552
  }
1969
2553
 
1970
2554
  // src/renderers/card.ts
@@ -2091,12 +2675,12 @@ var MACOS_DOTS = [
2091
2675
  { fill: "#27C93F", stroke: "#1AAB29" }
2092
2676
  ];
2093
2677
  function drawMacosDots(ctx, x, y) {
2094
- for (const [index, dot] of MACOS_DOTS.entries()) {
2678
+ for (const [index, dot2] of MACOS_DOTS.entries()) {
2095
2679
  ctx.beginPath();
2096
2680
  ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2097
2681
  ctx.closePath();
2098
- ctx.fillStyle = dot.fill;
2099
- ctx.strokeStyle = dot.stroke;
2682
+ ctx.fillStyle = dot2.fill;
2683
+ ctx.strokeStyle = dot2.stroke;
2100
2684
  ctx.lineWidth = DOT_STROKE_WIDTH;
2101
2685
  ctx.fill();
2102
2686
  ctx.stroke();
@@ -2517,25 +3101,134 @@ function drawOrthogonalPath(ctx, from, to, style) {
2517
3101
  }
2518
3102
 
2519
3103
  // src/renderers/connection.ts
2520
- function center(rect) {
3104
+ var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
3105
+ function rectCenter(rect) {
2521
3106
  return {
2522
3107
  x: rect.x + rect.width / 2,
2523
3108
  y: rect.y + rect.height / 2
2524
3109
  };
2525
3110
  }
2526
- function edgeAnchor(rect, target) {
2527
- const c = center(rect);
3111
+ function edgeAnchor(bounds, target) {
3112
+ const c = rectCenter(bounds);
2528
3113
  const dx = target.x - c.x;
2529
3114
  const dy = target.y - c.y;
2530
- if (Math.abs(dx) >= Math.abs(dy)) {
2531
- return {
2532
- x: dx >= 0 ? rect.x + rect.width : rect.x,
2533
- y: c.y
2534
- };
3115
+ if (dx === 0 && dy === 0) {
3116
+ return { x: c.x, y: c.y - bounds.height / 2 };
3117
+ }
3118
+ const hw = bounds.width / 2;
3119
+ const hh = bounds.height / 2;
3120
+ const absDx = Math.abs(dx);
3121
+ const absDy = Math.abs(dy);
3122
+ const t = absDx * hh > absDy * hw ? hw / absDx : hh / absDy;
3123
+ return { x: c.x + dx * t, y: c.y + dy * t };
3124
+ }
3125
+ function outwardNormal(point, diagramCenter) {
3126
+ const dx = point.x - diagramCenter.x;
3127
+ const dy = point.y - diagramCenter.y;
3128
+ const len = Math.hypot(dx, dy) || 1;
3129
+ return { x: dx / len, y: dy / len };
3130
+ }
3131
+ function curveRoute(fromBounds, toBounds, diagramCenter, tension) {
3132
+ const fromCenter = rectCenter(fromBounds);
3133
+ const toCenter = rectCenter(toBounds);
3134
+ const p0 = edgeAnchor(fromBounds, toCenter);
3135
+ const p3 = edgeAnchor(toBounds, fromCenter);
3136
+ const dist = Math.hypot(p3.x - p0.x, p3.y - p0.y);
3137
+ const offset = dist * tension;
3138
+ const n0 = outwardNormal(p0, diagramCenter);
3139
+ const n3 = outwardNormal(p3, diagramCenter);
3140
+ const cp1 = { x: p0.x + n0.x * offset, y: p0.y + n0.y * offset };
3141
+ const cp2 = { x: p3.x + n3.x * offset, y: p3.y + n3.y * offset };
3142
+ return [p0, cp1, cp2, p3];
3143
+ }
3144
+ function dot(a, b) {
3145
+ return a.x * b.x + a.y * b.y;
3146
+ }
3147
+ function localToWorld(origin, axisX, axisY, local) {
3148
+ return {
3149
+ x: origin.x + axisX.x * local.x + axisY.x * local.y,
3150
+ y: origin.y + axisX.y * local.x + axisY.y * local.y
3151
+ };
3152
+ }
3153
+ function arcRoute(fromBounds, toBounds, diagramCenter, tension) {
3154
+ const fromCenter = rectCenter(fromBounds);
3155
+ const toCenter = rectCenter(toBounds);
3156
+ const start = edgeAnchor(fromBounds, toCenter);
3157
+ const end = edgeAnchor(toBounds, fromCenter);
3158
+ const chord = { x: end.x - start.x, y: end.y - start.y };
3159
+ const chordLength = Math.hypot(chord.x, chord.y);
3160
+ if (chordLength < 1e-6) {
3161
+ const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3162
+ return [
3163
+ [start, start, mid, mid],
3164
+ [mid, mid, end, end]
3165
+ ];
3166
+ }
3167
+ const axisX = { x: chord.x / chordLength, y: chord.y / chordLength };
3168
+ let axisY = { x: -axisX.y, y: axisX.x };
3169
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
3170
+ const outwardHint = outwardNormal(midpoint, diagramCenter);
3171
+ if (dot(axisY, outwardHint) < 0) {
3172
+ axisY = { x: -axisY.x, y: -axisY.y };
3173
+ }
3174
+ const semiMajor = chordLength / 2;
3175
+ const semiMinor = Math.max(12, chordLength * tension * 0.75);
3176
+ const p0Local = { x: -semiMajor, y: 0 };
3177
+ const cp1Local = { x: -semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3178
+ const cp2Local = { x: -ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3179
+ const pMidLocal = { x: 0, y: semiMinor };
3180
+ const cp3Local = { x: ELLIPSE_KAPPA * semiMajor, y: semiMinor };
3181
+ const cp4Local = { x: semiMajor, y: ELLIPSE_KAPPA * semiMinor };
3182
+ const p3Local = { x: semiMajor, y: 0 };
3183
+ const p0 = localToWorld(midpoint, axisX, axisY, p0Local);
3184
+ const cp1 = localToWorld(midpoint, axisX, axisY, cp1Local);
3185
+ const cp2 = localToWorld(midpoint, axisX, axisY, cp2Local);
3186
+ const pMid = localToWorld(midpoint, axisX, axisY, pMidLocal);
3187
+ const cp3 = localToWorld(midpoint, axisX, axisY, cp3Local);
3188
+ const cp4 = localToWorld(midpoint, axisX, axisY, cp4Local);
3189
+ const p3 = localToWorld(midpoint, axisX, axisY, p3Local);
3190
+ return [
3191
+ [p0, cp1, cp2, pMid],
3192
+ [pMid, cp3, cp4, p3]
3193
+ ];
3194
+ }
3195
+ function orthogonalRoute(fromBounds, toBounds) {
3196
+ const fromC = rectCenter(fromBounds);
3197
+ const toC = rectCenter(toBounds);
3198
+ const p0 = edgeAnchor(fromBounds, toC);
3199
+ const p3 = edgeAnchor(toBounds, fromC);
3200
+ const midX = (p0.x + p3.x) / 2;
3201
+ return [p0, { x: midX, y: p0.y }, { x: midX, y: p3.y }, p3];
3202
+ }
3203
+ function bezierPointAt(p0, cp1, cp2, p3, t) {
3204
+ const mt = 1 - t;
3205
+ return {
3206
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * cp1.x + 3 * mt * t * t * cp2.x + t * t * t * p3.x,
3207
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * cp1.y + 3 * mt * t * t * cp2.y + t * t * t * p3.y
3208
+ };
3209
+ }
3210
+ function pointAlongArc(route, t) {
3211
+ const [first, second] = route;
3212
+ if (t <= 0.5) {
3213
+ const localT2 = Math.max(0, Math.min(1, t * 2));
3214
+ return bezierPointAt(first[0], first[1], first[2], first[3], localT2);
3215
+ }
3216
+ const localT = Math.max(0, Math.min(1, (t - 0.5) * 2));
3217
+ return bezierPointAt(second[0], second[1], second[2], second[3], localT);
3218
+ }
3219
+ function computeDiagramCenter(nodeBounds, canvasCenter) {
3220
+ if (nodeBounds.length === 0) {
3221
+ return canvasCenter ?? { x: 0, y: 0 };
3222
+ }
3223
+ let totalX = 0;
3224
+ let totalY = 0;
3225
+ for (const bounds of nodeBounds) {
3226
+ totalX += bounds.x + bounds.width / 2;
3227
+ totalY += bounds.y + bounds.height / 2;
2535
3228
  }
2536
3229
  return {
2537
- x: c.x,
2538
- y: dy >= 0 ? rect.y + rect.height : rect.y
3230
+ x: totalX / nodeBounds.length,
3231
+ y: totalY / nodeBounds.length
2539
3232
  };
2540
3233
  }
2541
3234
  function dashFromStyle(style) {
@@ -2619,51 +3312,95 @@ function polylineBounds(points) {
2619
3312
  height: Math.max(1, maxY - minY)
2620
3313
  };
2621
3314
  }
2622
- function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
2623
- const fromCenter = center(fromBounds);
2624
- const toCenter = center(toBounds);
2625
- const from = edgeAnchor(fromBounds, toCenter);
2626
- const to = edgeAnchor(toBounds, fromCenter);
2627
- const dash = dashFromStyle(conn.style);
3315
+ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute, options) {
3316
+ const routing = conn.routing ?? "auto";
3317
+ const strokeStyle = conn.strokeStyle ?? conn.style ?? "solid";
3318
+ const strokeWidth = conn.width ?? conn.strokeWidth ?? 2;
3319
+ const tension = conn.tension ?? 0.35;
3320
+ const dash = dashFromStyle(strokeStyle);
2628
3321
  const style = {
2629
3322
  color: conn.color ?? theme.borderMuted,
2630
- width: conn.width ?? 2,
3323
+ width: strokeWidth,
2631
3324
  headSize: conn.arrowSize ?? 10,
2632
3325
  ...dash ? { dash } : {}
2633
3326
  };
2634
- 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];
2635
- const startSegment = points[1] ?? points[0];
2636
- const endStart = points[points.length - 2] ?? points[0];
2637
- const end = points[points.length - 1] ?? points[0];
2638
- let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
2639
- let endAngle = Math.atan2(end.y - endStart.y, end.x - endStart.x);
3327
+ const labelT = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
3328
+ const diagramCenter = options?.diagramCenter ?? computeDiagramCenter([fromBounds, toBounds]);
3329
+ let linePoints;
3330
+ let startPoint;
3331
+ let endPoint;
3332
+ let startAngle;
3333
+ let endAngle;
3334
+ let labelPoint;
3335
+ ctx.save();
3336
+ ctx.globalAlpha = conn.opacity;
3337
+ if (routing === "curve") {
3338
+ const [p0, cp1, cp2, p3] = curveRoute(fromBounds, toBounds, diagramCenter, tension);
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, p3.x, p3.y);
3345
+ ctx.stroke();
3346
+ linePoints = [p0, cp1, cp2, p3];
3347
+ startPoint = p0;
3348
+ endPoint = p3;
3349
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3350
+ endAngle = Math.atan2(p3.y - cp2.y, p3.x - cp2.x);
3351
+ labelPoint = bezierPointAt(p0, cp1, cp2, p3, labelT);
3352
+ } else if (routing === "arc") {
3353
+ const [first, second] = arcRoute(fromBounds, toBounds, diagramCenter, tension);
3354
+ const [p0, cp1, cp2, pMid] = first;
3355
+ const [, cp3, cp4, p3] = second;
3356
+ ctx.strokeStyle = style.color;
3357
+ ctx.lineWidth = style.width;
3358
+ ctx.setLineDash(style.dash ?? []);
3359
+ ctx.beginPath();
3360
+ ctx.moveTo(p0.x, p0.y);
3361
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, pMid.x, pMid.y);
3362
+ ctx.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p3.x, p3.y);
3363
+ ctx.stroke();
3364
+ linePoints = [p0, cp1, cp2, pMid, cp3, cp4, p3];
3365
+ startPoint = p0;
3366
+ endPoint = p3;
3367
+ startAngle = Math.atan2(p0.y - cp1.y, p0.x - cp1.x);
3368
+ endAngle = Math.atan2(p3.y - cp4.y, p3.x - cp4.x);
3369
+ labelPoint = pointAlongArc([first, second], labelT);
3370
+ } else {
3371
+ const useElkRoute = routing === "auto" && (edgeRoute?.points.length ?? 0) >= 2;
3372
+ linePoints = useElkRoute ? edgeRoute?.points ?? orthogonalRoute(fromBounds, toBounds) : orthogonalRoute(fromBounds, toBounds);
3373
+ startPoint = linePoints[0];
3374
+ const startSegment = linePoints[1] ?? linePoints[0];
3375
+ const endStart = linePoints[linePoints.length - 2] ?? linePoints[0];
3376
+ endPoint = linePoints[linePoints.length - 1] ?? linePoints[0];
3377
+ startAngle = Math.atan2(startSegment.y - linePoints[0].y, startSegment.x - linePoints[0].x) + Math.PI;
3378
+ endAngle = Math.atan2(endPoint.y - endStart.y, endPoint.x - endStart.x);
3379
+ if (useElkRoute) {
3380
+ drawCubicInterpolatedPath(ctx, linePoints, style);
3381
+ } else {
3382
+ drawOrthogonalPath(ctx, startPoint, endPoint, style);
3383
+ }
3384
+ labelPoint = pointAlongPolyline(linePoints, labelT);
3385
+ }
2640
3386
  if (!Number.isFinite(startAngle)) {
2641
3387
  startAngle = 0;
2642
3388
  }
2643
3389
  if (!Number.isFinite(endAngle)) {
2644
3390
  endAngle = 0;
2645
3391
  }
2646
- const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
2647
- const labelPoint = pointAlongPolyline(points, t);
2648
- ctx.save();
2649
- ctx.globalAlpha = conn.opacity;
2650
- if (edgeRoute && edgeRoute.points.length >= 2) {
2651
- drawCubicInterpolatedPath(ctx, points, style);
2652
- } else {
2653
- drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
2654
- }
2655
3392
  if (conn.arrow === "start" || conn.arrow === "both") {
2656
- drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
3393
+ drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
2657
3394
  }
2658
3395
  if (conn.arrow === "end" || conn.arrow === "both") {
2659
- drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
3396
+ drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
2660
3397
  }
2661
3398
  ctx.restore();
2662
3399
  const elements = [
2663
3400
  {
2664
3401
  id: `connection-${conn.from}-${conn.to}`,
2665
3402
  kind: "connection",
2666
- bounds: polylineBounds(points),
3403
+ bounds: polylineBounds(linePoints),
2667
3404
  foregroundColor: style.color
2668
3405
  }
2669
3406
  ];
@@ -3294,92 +4031,6 @@ function renderDrawCommands(ctx, commands, theme) {
3294
4031
  return rendered;
3295
4032
  }
3296
4033
 
3297
- // src/renderers/flow-node.ts
3298
- function renderFlowNode(ctx, node, bounds, theme) {
3299
- const fillColor = node.color ?? theme.surfaceElevated;
3300
- const borderColor = node.borderColor ?? theme.border;
3301
- const borderWidth = node.borderWidth ?? 2;
3302
- const cornerRadius = node.cornerRadius ?? 16;
3303
- const labelColor = node.labelColor ?? theme.text;
3304
- const sublabelColor = node.sublabelColor ?? theme.textMuted;
3305
- const labelFontSize = node.labelFontSize ?? 20;
3306
- ctx.save();
3307
- ctx.globalAlpha = node.opacity;
3308
- ctx.lineWidth = borderWidth;
3309
- switch (node.shape) {
3310
- case "box":
3311
- drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
3312
- break;
3313
- case "rounded-box":
3314
- drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
3315
- break;
3316
- case "diamond":
3317
- drawDiamond(ctx, bounds, fillColor, borderColor);
3318
- break;
3319
- case "circle": {
3320
- const radius = Math.min(bounds.width, bounds.height) / 2;
3321
- drawCircle(
3322
- ctx,
3323
- { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
3324
- radius,
3325
- fillColor,
3326
- borderColor
3327
- );
3328
- break;
3329
- }
3330
- case "pill":
3331
- drawPill(ctx, bounds, fillColor, borderColor);
3332
- break;
3333
- case "cylinder":
3334
- drawCylinder(ctx, bounds, fillColor, borderColor);
3335
- break;
3336
- case "parallelogram":
3337
- drawParallelogram(ctx, bounds, fillColor, borderColor);
3338
- break;
3339
- }
3340
- const headingFont = resolveFont(theme.fonts.heading, "heading");
3341
- const bodyFont = resolveFont(theme.fonts.body, "body");
3342
- const centerX = bounds.x + bounds.width / 2;
3343
- const centerY = bounds.y + bounds.height / 2;
3344
- const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
3345
- ctx.textAlign = "center";
3346
- applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
3347
- ctx.fillStyle = labelColor;
3348
- ctx.fillText(node.label, centerX, labelY);
3349
- let textBoundsY = bounds.y + bounds.height / 2 - 18;
3350
- let textBoundsHeight = 36;
3351
- if (node.sublabel) {
3352
- const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
3353
- applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
3354
- ctx.fillStyle = sublabelColor;
3355
- ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
3356
- textBoundsY = bounds.y + bounds.height / 2 - 24;
3357
- textBoundsHeight = 56;
3358
- }
3359
- ctx.restore();
3360
- return [
3361
- {
3362
- id: `flow-node-${node.id}`,
3363
- kind: "flow-node",
3364
- bounds,
3365
- foregroundColor: labelColor,
3366
- backgroundColor: fillColor
3367
- },
3368
- {
3369
- id: `flow-node-${node.id}-label`,
3370
- kind: "text",
3371
- bounds: {
3372
- x: bounds.x + 8,
3373
- y: textBoundsY,
3374
- width: bounds.width - 16,
3375
- height: textBoundsHeight
3376
- },
3377
- foregroundColor: labelColor,
3378
- backgroundColor: fillColor
3379
- }
3380
- ];
3381
- }
3382
-
3383
4034
  // src/renderers/image.ts
3384
4035
  import { loadImage } from "@napi-rs/canvas";
3385
4036
  function roundedRectPath2(ctx, bounds, radius) {
@@ -3963,6 +4614,10 @@ async function renderDesign(input, options = {}) {
3963
4614
  break;
3964
4615
  }
3965
4616
  }
4617
+ const diagramCenter = spec.layout.diagramCenter ?? computeDiagramCenter(
4618
+ spec.elements.filter((element) => element.type !== "connection").map((element) => elementRects.get(element.id)).filter((rect) => rect != null),
4619
+ { x: spec.canvas.width / 2, y: spec.canvas.height / 2 }
4620
+ );
3966
4621
  for (const element of spec.elements) {
3967
4622
  if (element.type !== "connection") {
3968
4623
  continue;
@@ -3975,7 +4630,9 @@ async function renderDesign(input, options = {}) {
3975
4630
  );
3976
4631
  }
3977
4632
  const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
3978
- elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
4633
+ elements.push(
4634
+ ...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
4635
+ );
3979
4636
  }
3980
4637
  if (footerRect && spec.footer) {
3981
4638
  const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
@@ -4338,6 +4995,36 @@ var renderOutputSchema = z3.object({
4338
4995
  )
4339
4996
  })
4340
4997
  });
4998
+ var compareOutputSchema = z3.object({
4999
+ targetPath: z3.string(),
5000
+ renderedPath: z3.string(),
5001
+ targetDimensions: z3.object({
5002
+ width: z3.number().int().positive(),
5003
+ height: z3.number().int().positive()
5004
+ }),
5005
+ renderedDimensions: z3.object({
5006
+ width: z3.number().int().positive(),
5007
+ height: z3.number().int().positive()
5008
+ }),
5009
+ normalizedDimensions: z3.object({
5010
+ width: z3.number().int().positive(),
5011
+ height: z3.number().int().positive()
5012
+ }),
5013
+ dimensionMismatch: z3.boolean(),
5014
+ grid: z3.number().int().positive(),
5015
+ threshold: z3.number(),
5016
+ closeThreshold: z3.number(),
5017
+ similarity: z3.number(),
5018
+ verdict: z3.enum(["match", "close", "mismatch"]),
5019
+ regions: z3.array(
5020
+ z3.object({
5021
+ label: z3.string(),
5022
+ row: z3.number().int().nonnegative(),
5023
+ column: z3.number().int().nonnegative(),
5024
+ similarity: z3.number()
5025
+ })
5026
+ )
5027
+ });
4341
5028
  async function readJson(path) {
4342
5029
  if (path === "-") {
4343
5030
  const chunks = [];
@@ -4440,6 +5127,44 @@ cli.command("render", {
4440
5127
  return c.ok(runReport);
4441
5128
  }
4442
5129
  });
5130
+ cli.command("compare", {
5131
+ description: "Compare a rendered design against a target image using structural similarity scoring.",
5132
+ options: z3.object({
5133
+ target: z3.string().describe("Path to target image (baseline)"),
5134
+ rendered: z3.string().describe("Path to rendered image to evaluate"),
5135
+ grid: z3.number().int().positive().default(3).describe("Grid size for per-region scoring"),
5136
+ threshold: z3.number().min(0).max(1).default(0.8).describe("Minimum similarity score required for a match verdict")
5137
+ }),
5138
+ output: compareOutputSchema,
5139
+ examples: [
5140
+ {
5141
+ options: {
5142
+ target: "./designs/target.png",
5143
+ rendered: "./output/design-v2-g0.4.0-sabc123.png",
5144
+ grid: 3,
5145
+ threshold: 0.8
5146
+ },
5147
+ description: "Compare two images and report overall + per-region similarity scores"
5148
+ }
5149
+ ],
5150
+ async run(c) {
5151
+ try {
5152
+ return c.ok(
5153
+ await compareImages(c.options.target, c.options.rendered, {
5154
+ grid: c.options.grid,
5155
+ threshold: c.options.threshold
5156
+ })
5157
+ );
5158
+ } catch (error) {
5159
+ const message = error instanceof Error ? error.message : String(error);
5160
+ return c.error({
5161
+ code: "COMPARE_FAILED",
5162
+ message: `Unable to compare images: ${message}`,
5163
+ retryable: false
5164
+ });
5165
+ }
5166
+ }
5167
+ });
4443
5168
  var template = Cli.create("template", {
4444
5169
  description: "Generate common design templates and run the full render \u2192 QA pipeline."
4445
5170
  });
@@ -4681,7 +5406,8 @@ cli.command("qa", {
4681
5406
  options: z3.object({
4682
5407
  in: z3.string().describe("Path to rendered PNG"),
4683
5408
  spec: z3.string().describe("Path to normalized DesignSpec JSON"),
4684
- meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)")
5409
+ meta: z3.string().optional().describe("Optional sidecar metadata path (.meta.json)"),
5410
+ reference: z3.string().optional().describe("Optional reference image path for visual comparison")
4685
5411
  }),
4686
5412
  output: z3.object({
4687
5413
  pass: z3.boolean(),
@@ -4695,7 +5421,18 @@ cli.command("qa", {
4695
5421
  message: z3.string(),
4696
5422
  elementId: z3.string().optional()
4697
5423
  })
4698
- )
5424
+ ),
5425
+ reference: z3.object({
5426
+ similarity: z3.number(),
5427
+ verdict: z3.enum(["match", "close", "mismatch"]),
5428
+ regions: z3.array(
5429
+ z3.object({
5430
+ label: z3.string(),
5431
+ similarity: z3.number(),
5432
+ description: z3.string().optional()
5433
+ })
5434
+ )
5435
+ }).optional()
4699
5436
  }),
4700
5437
  examples: [
4701
5438
  {
@@ -4718,14 +5455,16 @@ cli.command("qa", {
4718
5455
  const report = await runQa({
4719
5456
  imagePath: c.options.in,
4720
5457
  spec,
4721
- ...metadata ? { metadata } : {}
5458
+ ...metadata ? { metadata } : {},
5459
+ ...c.options.reference ? { referencePath: c.options.reference } : {}
4722
5460
  });
4723
5461
  const response = {
4724
5462
  pass: report.pass,
4725
5463
  checkedAt: report.checkedAt,
4726
5464
  imagePath: report.imagePath,
4727
5465
  issueCount: report.issues.length,
4728
- issues: report.issues
5466
+ issues: report.issues,
5467
+ ...report.reference ? { reference: report.reference } : {}
4729
5468
  };
4730
5469
  if (!report.pass) {
4731
5470
  return c.error({
@@ -4859,9 +5598,14 @@ var isMain = (() => {
4859
5598
  if (isMain) {
4860
5599
  cli.serve();
4861
5600
  }
5601
+
5602
+ // src/index.ts
5603
+ init_compare();
4862
5604
  export {
4863
5605
  DEFAULT_GENERATOR_VERSION,
4864
5606
  DEFAULT_RAINBOW_COLORS,
5607
+ arcRoute,
5608
+ bezierPointAt,
4865
5609
  buildCardsSpec,
4866
5610
  buildCodeSpec,
4867
5611
  buildFlowchartSpec,
@@ -4869,7 +5613,11 @@ export {
4869
5613
  builtInThemeBackgrounds,
4870
5614
  builtInThemes,
4871
5615
  cli,
5616
+ compareImages,
5617
+ computeDiagramCenter,
4872
5618
  computeSpecHash,
5619
+ connectionElementSchema,
5620
+ curveRoute,
4873
5621
  defaultAutoLayout,
4874
5622
  defaultCanvas,
4875
5623
  defaultConstraints,
@@ -4879,19 +5627,29 @@ export {
4879
5627
  defaultTheme,
4880
5628
  deriveSafeFrame,
4881
5629
  designSpecSchema,
5630
+ diagramElementSchema,
5631
+ diagramLayoutSchema,
5632
+ diagramSpecSchema,
4882
5633
  disposeHighlighter,
4883
5634
  drawGradientRect,
4884
5635
  drawRainbowRule,
4885
5636
  drawVignette,
5637
+ edgeAnchor,
5638
+ flowNodeElementSchema,
4886
5639
  highlightCode,
4887
5640
  inferLayout,
4888
5641
  inferSidecarPath,
4889
5642
  initHighlighter,
4890
5643
  loadFonts,
5644
+ orthogonalRoute,
5645
+ outwardNormal,
4891
5646
  parseDesignSpec,
5647
+ parseDiagramSpec,
4892
5648
  publishToGist,
4893
5649
  publishToGitHub,
4894
5650
  readMetadata,
5651
+ rectCenter,
5652
+ renderConnection,
4895
5653
  renderDesign,
4896
5654
  renderDrawCommands,
4897
5655
  resolveShikiTheme,