@spectratools/graphic-designer-cli 0.4.0 → 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)";
@@ -338,6 +485,10 @@ function contrastRatio(foreground, background) {
338
485
  const darker = Math.min(fg, bg);
339
486
  return (lighter + 0.05) / (darker + 0.05);
340
487
  }
488
+ function withAlpha(hexColor, opacity) {
489
+ const rgb = parseHexColor(hexColor);
490
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
491
+ }
341
492
  function blendColorWithOpacity(foreground, background, opacity) {
342
493
  const fg = parseHexColor(foreground);
343
494
  const bg = parseHexColor(background);
@@ -773,10 +924,26 @@ var cardElementSchema = z2.object({
773
924
  tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
774
925
  icon: z2.string().min(1).max(64).optional()
775
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();
776
934
  var flowNodeElementSchema = z2.object({
777
935
  type: z2.literal("flow-node"),
778
936
  id: z2.string().min(1).max(120),
779
- 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"),
780
947
  label: z2.string().min(1).max(200),
781
948
  sublabel: z2.string().min(1).max(300).optional(),
782
949
  sublabelColor: colorHexSchema2.optional(),
@@ -796,20 +963,25 @@ var flowNodeElementSchema = z2.object({
796
963
  badgeText: z2.string().min(1).max(32).optional(),
797
964
  badgeColor: colorHexSchema2.optional(),
798
965
  badgeBackground: colorHexSchema2.optional(),
799
- badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
966
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
967
+ shadow: flowNodeShadowSchema.optional()
800
968
  }).strict();
801
969
  var connectionElementSchema = z2.object({
802
970
  type: z2.literal("connection"),
803
971
  from: z2.string().min(1).max(120),
804
972
  to: z2.string().min(1).max(120),
805
973
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
974
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
806
975
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
807
976
  label: z2.string().min(1).max(200).optional(),
808
977
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
809
978
  color: colorHexSchema2.optional(),
810
- 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),
811
981
  arrowSize: z2.number().min(4).max(32).optional(),
812
- 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)
813
985
  }).strict();
814
986
  var codeBlockStyleSchema = z2.object({
815
987
  paddingVertical: z2.number().min(0).max(128).default(56),
@@ -878,6 +1050,10 @@ var elementSchema = z2.discriminatedUnion("type", [
878
1050
  shapeElementSchema,
879
1051
  imageElementSchema
880
1052
  ]);
1053
+ var diagramCenterSchema = z2.object({
1054
+ x: z2.number(),
1055
+ y: z2.number()
1056
+ }).strict();
881
1057
  var autoLayoutConfigSchema = z2.object({
882
1058
  mode: z2.literal("auto"),
883
1059
  algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
@@ -893,7 +1069,9 @@ var autoLayoutConfigSchema = z2.object({
893
1069
  /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
894
1070
  radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
895
1071
  /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
896
- radialSortBy: z2.enum(["id", "connections"]).optional()
1072
+ radialSortBy: z2.enum(["id", "connections"]).optional(),
1073
+ /** Explicit center used by curve/arc connection routing. */
1074
+ diagramCenter: diagramCenterSchema.optional()
897
1075
  }).strict();
898
1076
  var gridLayoutConfigSchema = z2.object({
899
1077
  mode: z2.literal("grid"),
@@ -901,13 +1079,17 @@ var gridLayoutConfigSchema = z2.object({
901
1079
  gap: z2.number().int().min(0).max(256).default(24),
902
1080
  cardMinHeight: z2.number().int().min(32).max(4096).optional(),
903
1081
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
904
- equalHeight: z2.boolean().default(false)
1082
+ equalHeight: z2.boolean().default(false),
1083
+ /** Explicit center used by curve/arc connection routing. */
1084
+ diagramCenter: diagramCenterSchema.optional()
905
1085
  }).strict();
906
1086
  var stackLayoutConfigSchema = z2.object({
907
1087
  mode: z2.literal("stack"),
908
1088
  direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
909
1089
  gap: z2.number().int().min(0).max(256).default(24),
910
- 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()
911
1093
  }).strict();
912
1094
  var manualPositionSchema = z2.object({
913
1095
  x: z2.number().int(),
@@ -917,7 +1099,9 @@ var manualPositionSchema = z2.object({
917
1099
  }).strict();
918
1100
  var manualLayoutConfigSchema = z2.object({
919
1101
  mode: z2.literal("manual"),
920
- 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()
921
1105
  }).strict();
922
1106
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
923
1107
  autoLayoutConfigSchema,
@@ -969,6 +1153,31 @@ var canvasSchema = z2.object({
969
1153
  padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
970
1154
  }).strict();
971
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();
972
1181
  var designSpecSchema = z2.object({
973
1182
  version: z2.literal(2).default(2),
974
1183
  canvas: canvasSchema.default(defaultCanvas),
@@ -1042,7 +1251,7 @@ async function runQa(options) {
1042
1251
  const imagePath = resolve(options.imagePath);
1043
1252
  const expectedSafeFrame = deriveSafeFrame(spec);
1044
1253
  const expectedCanvas = canvasRect(spec);
1045
- const imageMetadata = await sharp(imagePath).metadata();
1254
+ const imageMetadata = await sharp2(imagePath).metadata();
1046
1255
  const issues = [];
1047
1256
  const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
1048
1257
  const expectedWidth = spec.canvas.width * expectedScale;
@@ -1193,6 +1402,31 @@ async function runQa(options) {
1193
1402
  });
1194
1403
  }
1195
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
+ }
1196
1430
  const footerSpacingPx = options.metadata?.layout.elements ? (() => {
1197
1431
  const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
1198
1432
  if (!footer) {
@@ -1225,7 +1459,8 @@ async function runQa(options) {
1225
1459
  ...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
1226
1460
  ...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
1227
1461
  },
1228
- issues
1462
+ issues,
1463
+ ...referenceResult ? { reference: referenceResult } : {}
1229
1464
  };
1230
1465
  }
1231
1466
 
@@ -1292,9 +1527,9 @@ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
1292
1527
  roundRectPath(ctx, rect, radius);
1293
1528
  fillAndStroke(ctx, fill, stroke);
1294
1529
  }
1295
- function drawCircle(ctx, center2, radius, fill, stroke) {
1530
+ function drawCircle(ctx, center, radius, fill, stroke) {
1296
1531
  ctx.beginPath();
1297
- ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
1532
+ ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2);
1298
1533
  ctx.closePath();
1299
1534
  fillAndStroke(ctx, fill, stroke);
1300
1535
  }
@@ -1538,15 +1773,34 @@ function renderFlowNode(ctx, node, bounds, theme) {
1538
1773
  const badgeBackground = node.badgeBackground ?? borderColor ?? theme.accent;
1539
1774
  ctx.save();
1540
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
+ }
1541
1783
  if (fillOpacity < 1) {
1542
1784
  ctx.globalAlpha = node.opacity * fillOpacity;
1543
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
+ }
1544
1792
  ctx.globalAlpha = node.opacity;
1545
1793
  drawNodeShape(ctx, node.shape, bounds, "rgba(0,0,0,0)", borderColor, cornerRadius);
1546
1794
  } else {
1547
1795
  ctx.globalAlpha = node.opacity;
1548
1796
  drawNodeShape(ctx, node.shape, bounds, fillColor, borderColor, cornerRadius);
1549
1797
  }
1798
+ if (node.shadow) {
1799
+ ctx.shadowColor = "transparent";
1800
+ ctx.shadowBlur = 0;
1801
+ ctx.shadowOffsetX = 0;
1802
+ ctx.shadowOffsetY = 0;
1803
+ }
1550
1804
  const headingFont = resolveFont(theme.fonts.heading, "heading");
1551
1805
  const bodyFont = resolveFont(theme.fonts.body, "body");
1552
1806
  const monoFont = resolveFont(theme.fonts.mono, "mono");
@@ -2218,7 +2472,7 @@ function parseHexColor2(color) {
2218
2472
  a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
2219
2473
  };
2220
2474
  }
2221
- function withAlpha(color, alpha) {
2475
+ function withAlpha2(color, alpha) {
2222
2476
  const parsed = parseHexColor2(color);
2223
2477
  const effectiveAlpha = clamp01(parsed.a * alpha);
2224
2478
  return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
@@ -2275,9 +2529,9 @@ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
2275
2529
  centerY,
2276
2530
  outerRadius
2277
2531
  );
2278
- vignette.addColorStop(0, withAlpha(color, 0));
2279
- vignette.addColorStop(0.6, withAlpha(color, 0));
2280
- vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
2532
+ vignette.addColorStop(0, withAlpha2(color, 0));
2533
+ vignette.addColorStop(0.6, withAlpha2(color, 0));
2534
+ vignette.addColorStop(1, withAlpha2(color, clamp01(intensity)));
2281
2535
  ctx.save();
2282
2536
  ctx.fillStyle = vignette;
2283
2537
  ctx.fillRect(0, 0, width, height);
@@ -2408,12 +2662,12 @@ var MACOS_DOTS = [
2408
2662
  { fill: "#27C93F", stroke: "#1AAB29" }
2409
2663
  ];
2410
2664
  function drawMacosDots(ctx, x, y) {
2411
- for (const [index, dot] of MACOS_DOTS.entries()) {
2665
+ for (const [index, dot2] of MACOS_DOTS.entries()) {
2412
2666
  ctx.beginPath();
2413
2667
  ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
2414
2668
  ctx.closePath();
2415
- ctx.fillStyle = dot.fill;
2416
- ctx.strokeStyle = dot.stroke;
2669
+ ctx.fillStyle = dot2.fill;
2670
+ ctx.strokeStyle = dot2.stroke;
2417
2671
  ctx.lineWidth = DOT_STROKE_WIDTH;
2418
2672
  ctx.fill();
2419
2673
  ctx.stroke();
@@ -2830,25 +3084,134 @@ function drawOrthogonalPath(ctx, from, to, style) {
2830
3084
  }
2831
3085
 
2832
3086
  // src/renderers/connection.ts
2833
- function center(rect) {
3087
+ var ELLIPSE_KAPPA = 4 * (Math.sqrt(2) - 1) / 3;
3088
+ function rectCenter(rect) {
2834
3089
  return {
2835
3090
  x: rect.x + rect.width / 2,
2836
3091
  y: rect.y + rect.height / 2
2837
3092
  };
2838
3093
  }
2839
- function edgeAnchor(rect, target) {
2840
- const c = center(rect);
3094
+ function edgeAnchor(bounds, target) {
3095
+ const c = rectCenter(bounds);
2841
3096
  const dx = target.x - c.x;
2842
3097
  const dy = target.y - c.y;
2843
- if (Math.abs(dx) >= Math.abs(dy)) {
2844
- return {
2845
- x: dx >= 0 ? rect.x + rect.width : rect.x,
2846
- y: c.y
2847
- };
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
+ ];
2848
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;
2849
3188
  return {
2850
- x: c.x,
2851
- y: dy >= 0 ? rect.y + rect.height : rect.y
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;
3211
+ }
3212
+ return {
3213
+ x: totalX / nodeBounds.length,
3214
+ y: totalY / nodeBounds.length
2852
3215
  };
2853
3216
  }
2854
3217
  function dashFromStyle(style) {
@@ -2932,51 +3295,95 @@ function polylineBounds(points) {
2932
3295
  height: Math.max(1, maxY - minY)
2933
3296
  };
2934
3297
  }
2935
- function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
2936
- const fromCenter = center(fromBounds);
2937
- const toCenter = center(toBounds);
2938
- const from = edgeAnchor(fromBounds, toCenter);
2939
- const to = edgeAnchor(toBounds, fromCenter);
2940
- 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);
2941
3304
  const style = {
2942
3305
  color: conn.color ?? theme.borderMuted,
2943
- width: conn.width ?? 2,
3306
+ width: strokeWidth,
2944
3307
  headSize: conn.arrowSize ?? 10,
2945
3308
  ...dash ? { dash } : {}
2946
3309
  };
2947
- 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];
2948
- const startSegment = points[1] ?? points[0];
2949
- const endStart = points[points.length - 2] ?? points[0];
2950
- const end = points[points.length - 1] ?? points[0];
2951
- let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
2952
- 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
+ }
2953
3369
  if (!Number.isFinite(startAngle)) {
2954
3370
  startAngle = 0;
2955
3371
  }
2956
3372
  if (!Number.isFinite(endAngle)) {
2957
3373
  endAngle = 0;
2958
3374
  }
2959
- const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
2960
- const labelPoint = pointAlongPolyline(points, t);
2961
- ctx.save();
2962
- ctx.globalAlpha = conn.opacity;
2963
- if (edgeRoute && edgeRoute.points.length >= 2) {
2964
- drawCubicInterpolatedPath(ctx, points, style);
2965
- } else {
2966
- drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
2967
- }
2968
3375
  if (conn.arrow === "start" || conn.arrow === "both") {
2969
- drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
3376
+ drawArrowhead(ctx, startPoint, startAngle, style.headSize, style.color);
2970
3377
  }
2971
3378
  if (conn.arrow === "end" || conn.arrow === "both") {
2972
- drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
3379
+ drawArrowhead(ctx, endPoint, endAngle, style.headSize, style.color);
2973
3380
  }
2974
3381
  ctx.restore();
2975
3382
  const elements = [
2976
3383
  {
2977
3384
  id: `connection-${conn.from}-${conn.to}`,
2978
3385
  kind: "connection",
2979
- bounds: polylineBounds(points),
3386
+ bounds: polylineBounds(linePoints),
2980
3387
  foregroundColor: style.color
2981
3388
  }
2982
3389
  ];
@@ -4190,6 +4597,10 @@ async function renderDesign(input, options = {}) {
4190
4597
  break;
4191
4598
  }
4192
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
+ );
4193
4604
  for (const element of spec.elements) {
4194
4605
  if (element.type !== "connection") {
4195
4606
  continue;
@@ -4202,7 +4613,9 @@ async function renderDesign(input, options = {}) {
4202
4613
  );
4203
4614
  }
4204
4615
  const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
4205
- elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
4616
+ elements.push(
4617
+ ...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute, { diagramCenter })
4618
+ );
4206
4619
  }
4207
4620
  if (footerRect && spec.footer) {
4208
4621
  const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
@@ -4565,6 +4978,36 @@ var renderOutputSchema = z3.object({
4565
4978
  )
4566
4979
  })
4567
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
+ });
4568
5011
  async function readJson(path) {
4569
5012
  if (path === "-") {
4570
5013
  const chunks = [];
@@ -4667,6 +5110,44 @@ cli.command("render", {
4667
5110
  return c.ok(runReport);
4668
5111
  }
4669
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
+ });
4670
5151
  var template = Cli.create("template", {
4671
5152
  description: "Generate common design templates and run the full render \u2192 QA pipeline."
4672
5153
  });
@@ -4908,7 +5389,8 @@ cli.command("qa", {
4908
5389
  options: z3.object({
4909
5390
  in: z3.string().describe("Path to rendered PNG"),
4910
5391
  spec: z3.string().describe("Path to normalized DesignSpec JSON"),
4911
- 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")
4912
5394
  }),
4913
5395
  output: z3.object({
4914
5396
  pass: z3.boolean(),
@@ -4922,7 +5404,18 @@ cli.command("qa", {
4922
5404
  message: z3.string(),
4923
5405
  elementId: z3.string().optional()
4924
5406
  })
4925
- )
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()
4926
5419
  }),
4927
5420
  examples: [
4928
5421
  {
@@ -4945,14 +5438,16 @@ cli.command("qa", {
4945
5438
  const report = await runQa({
4946
5439
  imagePath: c.options.in,
4947
5440
  spec,
4948
- ...metadata ? { metadata } : {}
5441
+ ...metadata ? { metadata } : {},
5442
+ ...c.options.reference ? { referencePath: c.options.reference } : {}
4949
5443
  });
4950
5444
  const response = {
4951
5445
  pass: report.pass,
4952
5446
  checkedAt: report.checkedAt,
4953
5447
  imagePath: report.imagePath,
4954
5448
  issueCount: report.issues.length,
4955
- issues: report.issues
5449
+ issues: report.issues,
5450
+ ...report.reference ? { reference: report.reference } : {}
4956
5451
  };
4957
5452
  if (!report.pass) {
4958
5453
  return c.error({