@spectratools/graphic-designer-cli 0.3.1

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.
@@ -0,0 +1,3608 @@
1
+ // src/renderer.ts
2
+ import { mkdir, writeFile } from "fs/promises";
3
+ import { basename, dirname as dirname2, extname, join, resolve as resolve2 } from "path";
4
+ import { createCanvas } from "@napi-rs/canvas";
5
+
6
+ // src/code-style.ts
7
+ var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
8
+ var DEFAULT_STYLE = {
9
+ paddingVertical: 56,
10
+ paddingHorizontal: 56,
11
+ windowControls: "macos",
12
+ dropShadow: true,
13
+ dropShadowOffsetY: 20,
14
+ dropShadowBlurRadius: 68,
15
+ surroundColor: CARBON_SURROUND_COLOR,
16
+ fontSize: 14,
17
+ lineHeightPercent: 143,
18
+ scale: 2
19
+ };
20
+ function normalizeScale(scale) {
21
+ if (scale === 1 || scale === 2 || scale === 4) {
22
+ return scale;
23
+ }
24
+ return DEFAULT_STYLE.scale;
25
+ }
26
+ function resolveCodeBlockStyle(style) {
27
+ return {
28
+ paddingVertical: style?.paddingVertical ?? DEFAULT_STYLE.paddingVertical,
29
+ paddingHorizontal: style?.paddingHorizontal ?? DEFAULT_STYLE.paddingHorizontal,
30
+ windowControls: style?.windowControls ?? DEFAULT_STYLE.windowControls,
31
+ dropShadow: style?.dropShadow ?? DEFAULT_STYLE.dropShadow,
32
+ dropShadowOffsetY: style?.dropShadowOffsetY ?? DEFAULT_STYLE.dropShadowOffsetY,
33
+ dropShadowBlurRadius: style?.dropShadowBlurRadius ?? DEFAULT_STYLE.dropShadowBlurRadius,
34
+ surroundColor: style?.surroundColor ?? DEFAULT_STYLE.surroundColor,
35
+ fontSize: style?.fontSize ?? DEFAULT_STYLE.fontSize,
36
+ lineHeightPercent: style?.lineHeightPercent ?? DEFAULT_STYLE.lineHeightPercent,
37
+ scale: normalizeScale(style?.scale)
38
+ };
39
+ }
40
+ function resolveElementScale(element) {
41
+ if (element.type !== "code-block" && element.type !== "terminal") {
42
+ return null;
43
+ }
44
+ return resolveCodeBlockStyle(element.style).scale;
45
+ }
46
+ function resolveRenderScale(spec) {
47
+ let scale = 1;
48
+ for (const element of spec.elements) {
49
+ const elementScale = resolveElementScale(element);
50
+ if (elementScale === null) {
51
+ continue;
52
+ }
53
+ if (elementScale > scale) {
54
+ scale = elementScale;
55
+ }
56
+ }
57
+ return scale;
58
+ }
59
+
60
+ // src/fonts.ts
61
+ import { dirname, resolve } from "path";
62
+ import { fileURLToPath } from "url";
63
+ import { GlobalFonts } from "@napi-rs/canvas";
64
+ var __dirname = dirname(fileURLToPath(import.meta.url));
65
+ var fontsDir = resolve(__dirname, "../fonts");
66
+ var loaded = false;
67
+ function register(filename, family) {
68
+ GlobalFonts.registerFromPath(resolve(fontsDir, filename), family);
69
+ }
70
+ function loadFonts() {
71
+ if (loaded) {
72
+ return;
73
+ }
74
+ register("Inter-Regular.woff2", "Inter");
75
+ register("Inter-Medium.woff2", "Inter");
76
+ register("Inter-SemiBold.woff2", "Inter");
77
+ register("Inter-Bold.woff2", "Inter");
78
+ register("JetBrainsMono-Regular.woff2", "JetBrains Mono");
79
+ register("JetBrainsMono-Medium.woff2", "JetBrains Mono");
80
+ register("JetBrainsMono-Bold.woff2", "JetBrains Mono");
81
+ register("SpaceGrotesk-Medium.woff2", "Space Grotesk");
82
+ register("SpaceGrotesk-Bold.woff2", "Space Grotesk");
83
+ loaded = true;
84
+ }
85
+
86
+ // src/layout/elk.ts
87
+ import ELK from "elkjs";
88
+
89
+ // src/layout/estimates.ts
90
+ function estimateElementHeight(element) {
91
+ switch (element.type) {
92
+ case "card":
93
+ return 220;
94
+ case "flow-node":
95
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 130;
96
+ case "code-block":
97
+ return 260;
98
+ case "terminal":
99
+ return 245;
100
+ case "text":
101
+ return element.style === "heading" ? 140 : element.style === "subheading" ? 110 : 90;
102
+ case "shape":
103
+ return 130;
104
+ case "image":
105
+ return 220;
106
+ case "connection":
107
+ return 0;
108
+ }
109
+ }
110
+ function estimateElementWidth(element) {
111
+ switch (element.type) {
112
+ case "card":
113
+ return 320;
114
+ case "flow-node":
115
+ return element.shape === "circle" || element.shape === "diamond" ? 160 : 220;
116
+ case "code-block":
117
+ return 420;
118
+ case "terminal":
119
+ return 420;
120
+ case "text":
121
+ return 360;
122
+ case "shape":
123
+ return 280;
124
+ case "image":
125
+ return 320;
126
+ case "connection":
127
+ return 0;
128
+ }
129
+ }
130
+
131
+ // src/layout/stack.ts
132
+ function computeStackLayout(elements, config, safeFrame) {
133
+ const placeable = elements.filter((element) => element.type !== "connection");
134
+ const positions = /* @__PURE__ */ new Map();
135
+ if (placeable.length === 0) {
136
+ return { positions };
137
+ }
138
+ const gap = config.gap;
139
+ if (config.direction === "vertical") {
140
+ const estimatedHeights = placeable.map((element) => estimateElementHeight(element));
141
+ const totalEstimated2 = estimatedHeights.reduce((sum, value) => sum + value, 0);
142
+ const available2 = Math.max(0, safeFrame.height - gap * (placeable.length - 1));
143
+ const scale2 = totalEstimated2 > 0 ? Math.min(1, available2 / totalEstimated2) : 1;
144
+ let y = safeFrame.y;
145
+ for (const [index, element] of placeable.entries()) {
146
+ const stretched = config.alignment === "stretch";
147
+ const width = stretched ? safeFrame.width : Math.min(safeFrame.width, Math.floor(estimateElementWidth(element)));
148
+ const height = Math.max(48, Math.floor(estimatedHeights[index] * scale2));
149
+ let x2 = safeFrame.x;
150
+ if (!stretched) {
151
+ if (config.alignment === "center") {
152
+ x2 = safeFrame.x + Math.floor((safeFrame.width - width) / 2);
153
+ } else if (config.alignment === "end") {
154
+ x2 = safeFrame.x + safeFrame.width - width;
155
+ }
156
+ }
157
+ positions.set(element.id, { x: x2, y, width, height });
158
+ y += height + gap;
159
+ }
160
+ return { positions };
161
+ }
162
+ const estimatedWidths = placeable.map((element) => estimateElementWidth(element));
163
+ const totalEstimated = estimatedWidths.reduce((sum, value) => sum + value, 0);
164
+ const available = Math.max(0, safeFrame.width - gap * (placeable.length - 1));
165
+ const scale = totalEstimated > 0 ? Math.min(1, available / totalEstimated) : 1;
166
+ let x = safeFrame.x;
167
+ for (const [index, element] of placeable.entries()) {
168
+ const stretched = config.alignment === "stretch";
169
+ const height = stretched ? safeFrame.height : Math.min(safeFrame.height, Math.floor(estimateElementHeight(element)));
170
+ const width = Math.max(64, Math.floor(estimatedWidths[index] * scale));
171
+ let y = safeFrame.y;
172
+ if (!stretched) {
173
+ if (config.alignment === "center") {
174
+ y = safeFrame.y + Math.floor((safeFrame.height - height) / 2);
175
+ } else if (config.alignment === "end") {
176
+ y = safeFrame.y + safeFrame.height - height;
177
+ }
178
+ }
179
+ positions.set(element.id, { x, y, width, height });
180
+ x += width + gap;
181
+ }
182
+ return { positions };
183
+ }
184
+
185
+ // src/layout/elk.ts
186
+ function estimateFlowNodeSize(node) {
187
+ if (node.width && node.height) {
188
+ return { width: node.width, height: node.height };
189
+ }
190
+ if (node.width) {
191
+ return {
192
+ width: node.width,
193
+ height: node.shape === "diamond" || node.shape === "circle" ? node.width : 60
194
+ };
195
+ }
196
+ if (node.height) {
197
+ return {
198
+ width: node.shape === "diamond" || node.shape === "circle" ? node.height : 160,
199
+ height: node.height
200
+ };
201
+ }
202
+ switch (node.shape) {
203
+ case "diamond":
204
+ case "circle":
205
+ return { width: 100, height: 100 };
206
+ case "pill":
207
+ return { width: 180, height: 56 };
208
+ case "cylinder":
209
+ return { width: 140, height: 92 };
210
+ case "parallelogram":
211
+ return { width: 180, height: 72 };
212
+ default:
213
+ return { width: 170, height: 64 };
214
+ }
215
+ }
216
+ function splitLayoutFrames(safeFrame, direction, hasAuxiliary) {
217
+ if (!hasAuxiliary) {
218
+ return { flowFrame: safeFrame };
219
+ }
220
+ const isHorizontal = direction === "LR" || direction === "RL";
221
+ const gap = Math.min(
222
+ 32,
223
+ Math.max(16, Math.floor(Math.min(safeFrame.width, safeFrame.height) * 0.03))
224
+ );
225
+ if (isHorizontal) {
226
+ const flowWidth = Math.max(120, Math.floor(safeFrame.width * 0.7) - Math.floor(gap / 2));
227
+ const auxiliaryWidth = Math.max(120, safeFrame.width - flowWidth - gap);
228
+ return {
229
+ flowFrame: {
230
+ x: safeFrame.x,
231
+ y: safeFrame.y,
232
+ width: flowWidth,
233
+ height: safeFrame.height
234
+ },
235
+ auxiliaryFrame: {
236
+ x: safeFrame.x + flowWidth + gap,
237
+ y: safeFrame.y,
238
+ width: auxiliaryWidth,
239
+ height: safeFrame.height
240
+ }
241
+ };
242
+ }
243
+ const flowHeight = Math.max(120, Math.floor(safeFrame.height * 0.7) - Math.floor(gap / 2));
244
+ const auxiliaryHeight = Math.max(120, safeFrame.height - flowHeight - gap);
245
+ return {
246
+ flowFrame: {
247
+ x: safeFrame.x,
248
+ y: safeFrame.y,
249
+ width: safeFrame.width,
250
+ height: flowHeight
251
+ },
252
+ auxiliaryFrame: {
253
+ x: safeFrame.x,
254
+ y: safeFrame.y + flowHeight + gap,
255
+ width: safeFrame.width,
256
+ height: auxiliaryHeight
257
+ }
258
+ };
259
+ }
260
+ function computeBounds(nodes) {
261
+ const minX = Math.min(...nodes.map((node) => node.x));
262
+ const minY = Math.min(...nodes.map((node) => node.y));
263
+ const maxX = Math.max(...nodes.map((node) => node.x + node.width));
264
+ const maxY = Math.max(...nodes.map((node) => node.y + node.height));
265
+ return { minX, minY, maxX, maxY };
266
+ }
267
+ function computeTransform(bounds, targetFrame) {
268
+ const padding = 8;
269
+ const graphWidth = Math.max(1, bounds.maxX - bounds.minX);
270
+ const graphHeight = Math.max(1, bounds.maxY - bounds.minY);
271
+ const usableWidth = Math.max(1, targetFrame.width - padding * 2);
272
+ const usableHeight = Math.max(1, targetFrame.height - padding * 2);
273
+ const scale = Math.min(usableWidth / graphWidth, usableHeight / graphHeight, 1);
274
+ const offsetX = targetFrame.x + padding + (usableWidth - graphWidth * scale) / 2 - bounds.minX * scale;
275
+ const offsetY = targetFrame.y + padding + (usableHeight - graphHeight * scale) / 2 - bounds.minY * scale;
276
+ return { scale, offsetX, offsetY };
277
+ }
278
+ function transformPoint(point, transform) {
279
+ return {
280
+ x: Math.round(point.x * transform.scale + transform.offsetX),
281
+ y: Math.round(point.y * transform.scale + transform.offsetY)
282
+ };
283
+ }
284
+ function toLayoutRect(node, transform) {
285
+ return {
286
+ x: Math.round(node.x * transform.scale + transform.offsetX),
287
+ y: Math.round(node.y * transform.scale + transform.offsetY),
288
+ width: Math.max(36, Math.round(node.width * transform.scale)),
289
+ height: Math.max(28, Math.round(node.height * transform.scale))
290
+ };
291
+ }
292
+ function routeKey(connection) {
293
+ return `${connection.from}-${connection.to}`;
294
+ }
295
+ function edgeRoutingToElk(edgeRouting) {
296
+ switch (edgeRouting) {
297
+ case "orthogonal":
298
+ return "ORTHOGONAL";
299
+ case "spline":
300
+ return "SPLINES";
301
+ default:
302
+ return "POLYLINE";
303
+ }
304
+ }
305
+ function algorithmToElk(algorithm) {
306
+ switch (algorithm) {
307
+ case "stress":
308
+ return "stress";
309
+ case "force":
310
+ return "force";
311
+ case "radial":
312
+ return "radial";
313
+ case "box":
314
+ return "rectpacking";
315
+ default:
316
+ return "layered";
317
+ }
318
+ }
319
+ function directionToElk(direction) {
320
+ switch (direction) {
321
+ case "BT":
322
+ return "UP";
323
+ case "LR":
324
+ return "RIGHT";
325
+ case "RL":
326
+ return "LEFT";
327
+ default:
328
+ return "DOWN";
329
+ }
330
+ }
331
+ function fallbackForNoFlowNodes(nonFlow, safeFrame) {
332
+ const fallbackConfig = {
333
+ mode: "stack",
334
+ direction: "vertical",
335
+ gap: 24,
336
+ alignment: "stretch"
337
+ };
338
+ return computeStackLayout(nonFlow, fallbackConfig, safeFrame);
339
+ }
340
+ async function computeElkLayout(elements, config, safeFrame) {
341
+ const positions = /* @__PURE__ */ new Map();
342
+ const edgeRoutes = /* @__PURE__ */ new Map();
343
+ const flowNodes = elements.filter(
344
+ (element) => element.type === "flow-node"
345
+ );
346
+ const connections = elements.filter(
347
+ (element) => element.type === "connection"
348
+ );
349
+ const nonFlow = elements.filter(
350
+ (element) => element.type !== "flow-node" && element.type !== "connection"
351
+ );
352
+ if (flowNodes.length === 0) {
353
+ return fallbackForNoFlowNodes(nonFlow, safeFrame);
354
+ }
355
+ const { flowFrame, auxiliaryFrame } = splitLayoutFrames(
356
+ safeFrame,
357
+ config.direction,
358
+ nonFlow.length > 0
359
+ );
360
+ const flowNodeIds = new Set(flowNodes.map((node) => node.id));
361
+ const elkNodeSizes = /* @__PURE__ */ new Map();
362
+ for (const node of flowNodes) {
363
+ elkNodeSizes.set(node.id, estimateFlowNodeSize(node));
364
+ }
365
+ const edgeIdToRouteKey = /* @__PURE__ */ new Map();
366
+ const elkGraph = {
367
+ id: "root",
368
+ layoutOptions: {
369
+ "elk.algorithm": algorithmToElk(config.algorithm),
370
+ "elk.direction": directionToElk(config.direction),
371
+ "elk.spacing.nodeNode": String(config.nodeSpacing),
372
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(config.rankSpacing),
373
+ "elk.edgeRouting": edgeRoutingToElk(config.edgeRouting),
374
+ ...config.aspectRatio ? { "elk.aspectRatio": String(config.aspectRatio) } : {},
375
+ ...config.algorithm === "stress" ? { "elk.stress.desiredEdgeLength": String(config.rankSpacing + config.nodeSpacing) } : {}
376
+ },
377
+ children: flowNodes.map((node) => {
378
+ const size = elkNodeSizes.get(node.id) ?? { width: 160, height: 60 };
379
+ return {
380
+ id: node.id,
381
+ width: size.width,
382
+ height: size.height
383
+ };
384
+ }),
385
+ edges: connections.filter((connection) => flowNodeIds.has(connection.from) && flowNodeIds.has(connection.to)).map((connection, index) => {
386
+ const id = `edge-${index}-${connection.from}-${connection.to}`;
387
+ edgeIdToRouteKey.set(id, routeKey(connection));
388
+ return {
389
+ id,
390
+ sources: [connection.from],
391
+ targets: [connection.to]
392
+ };
393
+ })
394
+ };
395
+ const elk = new ELK.default();
396
+ const result = await elk.layout(elkGraph);
397
+ const laidOutNodes = (result.children ?? []).filter(
398
+ (node) => typeof node.id === "string" && typeof node.x === "number" && typeof node.y === "number" && typeof node.width === "number" && typeof node.height === "number"
399
+ );
400
+ if (laidOutNodes.length > 0) {
401
+ const bounds = computeBounds(laidOutNodes);
402
+ const transform = computeTransform(bounds, flowFrame);
403
+ for (const node of laidOutNodes) {
404
+ positions.set(node.id, toLayoutRect(node, transform));
405
+ }
406
+ for (const edge of result.edges ?? []) {
407
+ const route = edgeIdToRouteKey.get(edge.id ?? "");
408
+ if (!route) {
409
+ continue;
410
+ }
411
+ const points = [];
412
+ for (const section of edge.sections ?? []) {
413
+ if (section.startPoint) {
414
+ points.push(transformPoint(section.startPoint, transform));
415
+ }
416
+ for (const bend of section.bendPoints ?? []) {
417
+ points.push(transformPoint(bend, transform));
418
+ }
419
+ if (section.endPoint) {
420
+ points.push(transformPoint(section.endPoint, transform));
421
+ }
422
+ }
423
+ const deduped = points.filter((point, index, all) => {
424
+ if (index === 0) {
425
+ return true;
426
+ }
427
+ const prev = all[index - 1];
428
+ return prev.x !== point.x || prev.y !== point.y;
429
+ });
430
+ if (deduped.length >= 2) {
431
+ edgeRoutes.set(route, { points: deduped });
432
+ }
433
+ }
434
+ }
435
+ if (nonFlow.length > 0) {
436
+ const stackConfig = {
437
+ mode: "stack",
438
+ direction: config.direction === "LR" || config.direction === "RL" ? "vertical" : "horizontal",
439
+ gap: 20,
440
+ alignment: "stretch"
441
+ };
442
+ const supplemental = computeStackLayout(nonFlow, stackConfig, auxiliaryFrame ?? safeFrame);
443
+ for (const [id, rect] of supplemental.positions) {
444
+ positions.set(id, rect);
445
+ }
446
+ }
447
+ return {
448
+ positions,
449
+ edgeRoutes
450
+ };
451
+ }
452
+
453
+ // src/layout/grid.ts
454
+ function computeGridLayout(elements, config, safeFrame) {
455
+ const placeable = elements.filter((element) => element.type !== "connection");
456
+ const positions = /* @__PURE__ */ new Map();
457
+ if (placeable.length === 0) {
458
+ return { positions };
459
+ }
460
+ const columns = Math.max(1, Math.min(config.columns, placeable.length));
461
+ const rows = Math.ceil(placeable.length / columns);
462
+ const gap = config.gap;
463
+ const availableWidth = Math.max(0, safeFrame.width - gap * (columns - 1));
464
+ const cellWidth = Math.floor(availableWidth / columns);
465
+ const rowElements = [];
466
+ for (let row = 0; row < rows; row += 1) {
467
+ const start = row * columns;
468
+ rowElements.push(placeable.slice(start, start + columns));
469
+ }
470
+ const equalRowHeight = Math.floor((safeFrame.height - gap * (rows - 1)) / rows);
471
+ const estimatedRowHeights = rowElements.map((row) => {
472
+ if (config.equalHeight) {
473
+ return equalRowHeight;
474
+ }
475
+ return Math.max(...row.map((element) => estimateElementHeight(element)));
476
+ });
477
+ const estimatedTotalHeight = estimatedRowHeights.reduce((sum, height) => sum + height, 0) + gap * (rows - 1);
478
+ const availableHeight = Math.max(0, safeFrame.height);
479
+ const scale = estimatedTotalHeight > 0 ? Math.min(1, availableHeight / estimatedTotalHeight) : 1;
480
+ const rowHeights = estimatedRowHeights.map((height) => Math.max(48, Math.floor(height * scale)));
481
+ let y = safeFrame.y;
482
+ let index = 0;
483
+ for (let row = 0; row < rows; row += 1) {
484
+ const rowHeight = rowHeights[row];
485
+ for (let col = 0; col < rowElements[row].length; col += 1) {
486
+ const element = placeable[index];
487
+ const x = safeFrame.x + col * (cellWidth + gap);
488
+ positions.set(element.id, {
489
+ x,
490
+ y,
491
+ width: cellWidth,
492
+ height: rowHeight
493
+ });
494
+ index += 1;
495
+ }
496
+ y += rowHeight + gap;
497
+ }
498
+ return { positions };
499
+ }
500
+
501
+ // src/layout/index.ts
502
+ function defaultManualSize(total, safeFrame) {
503
+ return {
504
+ x: safeFrame.x,
505
+ y: safeFrame.y,
506
+ width: Math.floor(safeFrame.width / 2),
507
+ height: Math.floor(safeFrame.height / Math.max(1, total))
508
+ };
509
+ }
510
+ function computeManualLayout(elements, layout, safeFrame) {
511
+ const positions = /* @__PURE__ */ new Map();
512
+ const placeable = elements.filter((element) => element.type !== "connection");
513
+ if (layout.mode !== "manual") {
514
+ return { positions };
515
+ }
516
+ const fallbackGrid = computeGridLayout(
517
+ placeable,
518
+ { mode: "grid", columns: 3, gap: 24, equalHeight: false },
519
+ safeFrame
520
+ );
521
+ for (const element of placeable) {
522
+ const manual = layout.positions[element.id];
523
+ if (!manual) {
524
+ const fallbackRect = fallbackGrid.positions.get(element.id);
525
+ if (fallbackRect) {
526
+ positions.set(element.id, fallbackRect);
527
+ }
528
+ continue;
529
+ }
530
+ const fallback = defaultManualSize(placeable.length, safeFrame);
531
+ positions.set(element.id, {
532
+ x: manual.x,
533
+ y: manual.y,
534
+ width: manual.width ?? fallback.width,
535
+ height: manual.height ?? fallback.height
536
+ });
537
+ }
538
+ return { positions };
539
+ }
540
+ async function computeLayout(elements, layout, safeFrame) {
541
+ switch (layout.mode) {
542
+ case "auto":
543
+ return computeElkLayout(elements, layout, safeFrame);
544
+ case "grid":
545
+ return computeGridLayout(elements, layout, safeFrame);
546
+ case "stack":
547
+ return computeStackLayout(elements, layout, safeFrame);
548
+ case "manual":
549
+ return computeManualLayout(elements, layout, safeFrame);
550
+ default:
551
+ return computeGridLayout(
552
+ elements,
553
+ { mode: "grid", columns: 3, gap: 24, equalHeight: false },
554
+ safeFrame
555
+ );
556
+ }
557
+ }
558
+
559
+ // src/primitives/gradients.ts
560
+ var DEFAULT_RAINBOW_COLORS = [
561
+ "#FF6B6B",
562
+ "#FFA94D",
563
+ "#FFD43B",
564
+ "#69DB7C",
565
+ "#4DABF7",
566
+ "#9775FA",
567
+ "#DA77F2"
568
+ ];
569
+ function clamp01(value) {
570
+ return Math.max(0, Math.min(1, value));
571
+ }
572
+ function normalizeStops(stops) {
573
+ return [...stops].map((stop) => ({
574
+ offset: clamp01(stop.offset),
575
+ color: stop.color
576
+ })).sort((a, b) => a.offset - b.offset);
577
+ }
578
+ function addGradientStops(gradient, stops) {
579
+ for (const stop of normalizeStops(stops)) {
580
+ gradient.addColorStop(stop.offset, stop.color);
581
+ }
582
+ }
583
+ function createLinearRectGradient(ctx, rect, angleDegrees) {
584
+ const radians = angleDegrees * Math.PI / 180;
585
+ const dx = Math.sin(radians);
586
+ const dy = -Math.cos(radians);
587
+ const cx = rect.x + rect.width / 2;
588
+ const cy = rect.y + rect.height / 2;
589
+ const halfSpan = Math.max(1, Math.abs(dx) * (rect.width / 2) + Math.abs(dy) * (rect.height / 2));
590
+ return ctx.createLinearGradient(
591
+ cx - dx * halfSpan,
592
+ cy - dy * halfSpan,
593
+ cx + dx * halfSpan,
594
+ cy + dy * halfSpan
595
+ );
596
+ }
597
+ function roundedRectPath(ctx, x, y, width, height, radius) {
598
+ const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
599
+ ctx.beginPath();
600
+ ctx.moveTo(x + safeRadius, y);
601
+ ctx.lineTo(x + width - safeRadius, y);
602
+ ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius);
603
+ ctx.lineTo(x + width, y + height - safeRadius);
604
+ ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height);
605
+ ctx.lineTo(x + safeRadius, y + height);
606
+ ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius);
607
+ ctx.lineTo(x, y + safeRadius);
608
+ ctx.quadraticCurveTo(x, y, x + safeRadius, y);
609
+ ctx.closePath();
610
+ }
611
+ function parseHexColor(color) {
612
+ const normalized = color.startsWith("#") ? color.slice(1) : color;
613
+ if (normalized.length !== 6 && normalized.length !== 8) {
614
+ throw new Error(`Expected #RRGGBB or #RRGGBBAA color, received ${color}`);
615
+ }
616
+ const parseChannel2 = (offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16);
617
+ return {
618
+ r: parseChannel2(0),
619
+ g: parseChannel2(2),
620
+ b: parseChannel2(4),
621
+ a: normalized.length === 8 ? parseChannel2(6) / 255 : 1
622
+ };
623
+ }
624
+ function withAlpha(color, alpha) {
625
+ const parsed = parseHexColor(color);
626
+ const effectiveAlpha = clamp01(parsed.a * alpha);
627
+ return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${effectiveAlpha})`;
628
+ }
629
+ function drawGradientRect(ctx, rect, gradient, borderRadius = 0) {
630
+ const fill = gradient.type === "linear" ? createLinearRectGradient(ctx, rect, gradient.angle ?? 180) : ctx.createRadialGradient(
631
+ rect.x + rect.width / 2,
632
+ rect.y + rect.height / 2,
633
+ 0,
634
+ rect.x + rect.width / 2,
635
+ rect.y + rect.height / 2,
636
+ Math.max(rect.width, rect.height) / 2
637
+ );
638
+ addGradientStops(fill, gradient.stops);
639
+ ctx.save();
640
+ ctx.fillStyle = fill;
641
+ if (borderRadius > 0) {
642
+ roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, borderRadius);
643
+ ctx.fill();
644
+ } else {
645
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
646
+ }
647
+ ctx.restore();
648
+ }
649
+ function drawRainbowRule(ctx, x, y, width, thickness = 2, colors = [...DEFAULT_RAINBOW_COLORS], borderRadius = thickness / 2) {
650
+ if (width <= 0 || thickness <= 0) {
651
+ return;
652
+ }
653
+ const gradient = ctx.createLinearGradient(x, y, x + width, y);
654
+ const stops = colors.length >= 2 ? colors : [...DEFAULT_RAINBOW_COLORS];
655
+ for (const [index, color] of stops.entries()) {
656
+ gradient.addColorStop(index / (stops.length - 1), color);
657
+ }
658
+ const ruleTop = y - thickness / 2;
659
+ ctx.save();
660
+ roundedRectPath(ctx, x, ruleTop, width, thickness, borderRadius);
661
+ ctx.fillStyle = gradient;
662
+ ctx.fill();
663
+ ctx.restore();
664
+ }
665
+ function drawVignette(ctx, width, height, intensity = 0.3, color = "#000000") {
666
+ if (width <= 0 || height <= 0 || intensity <= 0) {
667
+ return;
668
+ }
669
+ const centerX = width / 2;
670
+ const centerY = height / 2;
671
+ const outerRadius = Math.max(width, height) / 2;
672
+ const innerRadius = Math.min(width, height) * 0.2;
673
+ const vignette = ctx.createRadialGradient(
674
+ centerX,
675
+ centerY,
676
+ innerRadius,
677
+ centerX,
678
+ centerY,
679
+ outerRadius
680
+ );
681
+ vignette.addColorStop(0, withAlpha(color, 0));
682
+ vignette.addColorStop(0.6, withAlpha(color, 0));
683
+ vignette.addColorStop(1, withAlpha(color, clamp01(intensity)));
684
+ ctx.save();
685
+ ctx.fillStyle = vignette;
686
+ ctx.fillRect(0, 0, width, height);
687
+ ctx.restore();
688
+ }
689
+
690
+ // src/primitives/shapes.ts
691
+ function roundRectPath(ctx, rect, radius) {
692
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
693
+ const right = rect.x + rect.width;
694
+ const bottom = rect.y + rect.height;
695
+ ctx.beginPath();
696
+ ctx.moveTo(rect.x + r, rect.y);
697
+ ctx.lineTo(right - r, rect.y);
698
+ ctx.quadraticCurveTo(right, rect.y, right, rect.y + r);
699
+ ctx.lineTo(right, bottom - r);
700
+ ctx.quadraticCurveTo(right, bottom, right - r, bottom);
701
+ ctx.lineTo(rect.x + r, bottom);
702
+ ctx.quadraticCurveTo(rect.x, bottom, rect.x, bottom - r);
703
+ ctx.lineTo(rect.x, rect.y + r);
704
+ ctx.quadraticCurveTo(rect.x, rect.y, rect.x + r, rect.y);
705
+ ctx.closePath();
706
+ }
707
+ function fillAndStroke(ctx, fill, stroke) {
708
+ ctx.fillStyle = fill;
709
+ ctx.fill();
710
+ if (stroke) {
711
+ ctx.strokeStyle = stroke;
712
+ ctx.stroke();
713
+ }
714
+ }
715
+ function drawRoundedRect(ctx, rect, radius, fill, stroke) {
716
+ roundRectPath(ctx, rect, radius);
717
+ fillAndStroke(ctx, fill, stroke);
718
+ }
719
+ function drawCircle(ctx, center2, radius, fill, stroke) {
720
+ ctx.beginPath();
721
+ ctx.arc(center2.x, center2.y, Math.max(0, radius), 0, Math.PI * 2);
722
+ ctx.closePath();
723
+ fillAndStroke(ctx, fill, stroke);
724
+ }
725
+ function drawDiamond(ctx, bounds, fill, stroke) {
726
+ const cx = bounds.x + bounds.width / 2;
727
+ const cy = bounds.y + bounds.height / 2;
728
+ ctx.beginPath();
729
+ ctx.moveTo(cx, bounds.y);
730
+ ctx.lineTo(bounds.x + bounds.width, cy);
731
+ ctx.lineTo(cx, bounds.y + bounds.height);
732
+ ctx.lineTo(bounds.x, cy);
733
+ ctx.closePath();
734
+ fillAndStroke(ctx, fill, stroke);
735
+ }
736
+ function drawPill(ctx, bounds, fill, stroke) {
737
+ drawRoundedRect(ctx, bounds, Math.min(bounds.width, bounds.height) / 2, fill, stroke);
738
+ }
739
+ function drawEllipse(ctx, bounds, fill, stroke) {
740
+ const cx = bounds.x + bounds.width / 2;
741
+ const cy = bounds.y + bounds.height / 2;
742
+ ctx.beginPath();
743
+ ctx.ellipse(
744
+ cx,
745
+ cy,
746
+ Math.max(0, bounds.width / 2),
747
+ Math.max(0, bounds.height / 2),
748
+ 0,
749
+ 0,
750
+ Math.PI * 2
751
+ );
752
+ ctx.closePath();
753
+ fillAndStroke(ctx, fill, stroke);
754
+ }
755
+ function drawCylinder(ctx, bounds, fill, stroke) {
756
+ const rx = Math.max(2, bounds.width / 2);
757
+ const ry = Math.max(2, Math.min(bounds.height * 0.18, 16));
758
+ const cx = bounds.x + bounds.width / 2;
759
+ const topCy = bounds.y + ry;
760
+ const bottomCy = bounds.y + bounds.height - ry;
761
+ ctx.beginPath();
762
+ ctx.moveTo(bounds.x, topCy);
763
+ ctx.ellipse(cx, topCy, rx, ry, 0, Math.PI, 0, true);
764
+ ctx.lineTo(bounds.x + bounds.width, bottomCy);
765
+ ctx.ellipse(cx, bottomCy, rx, ry, 0, 0, Math.PI, false);
766
+ ctx.closePath();
767
+ fillAndStroke(ctx, fill, stroke);
768
+ if (stroke) {
769
+ ctx.beginPath();
770
+ ctx.ellipse(cx, topCy, rx, ry, 0, 0, Math.PI * 2);
771
+ ctx.closePath();
772
+ ctx.strokeStyle = stroke;
773
+ ctx.stroke();
774
+ }
775
+ }
776
+ function drawParallelogram(ctx, bounds, fill, stroke, skew) {
777
+ const maxSkew = bounds.width * 0.45;
778
+ const skewX = Math.max(-maxSkew, Math.min(maxSkew, skew ?? bounds.width * 0.18));
779
+ ctx.beginPath();
780
+ ctx.moveTo(bounds.x + skewX, bounds.y);
781
+ ctx.lineTo(bounds.x + bounds.width, bounds.y);
782
+ ctx.lineTo(bounds.x + bounds.width - skewX, bounds.y + bounds.height);
783
+ ctx.lineTo(bounds.x, bounds.y + bounds.height);
784
+ ctx.closePath();
785
+ fillAndStroke(ctx, fill, stroke);
786
+ }
787
+
788
+ // src/primitives/text.ts
789
+ var SUPPORTED_FONT_FAMILIES = /* @__PURE__ */ new Set(["Inter", "JetBrains Mono", "Space Grotesk"]);
790
+ function resolveFont(requested, role) {
791
+ if (SUPPORTED_FONT_FAMILIES.has(requested)) {
792
+ return requested;
793
+ }
794
+ if (role === "mono" || /mono|code|terminal|console/iu.test(requested)) {
795
+ return "JetBrains Mono";
796
+ }
797
+ if (role === "heading" || /display|grotesk|headline/iu.test(requested)) {
798
+ return "Space Grotesk";
799
+ }
800
+ return "Inter";
801
+ }
802
+ function applyFont(ctx, options) {
803
+ ctx.font = `${options.weight} ${options.size}px ${options.family}`;
804
+ }
805
+ function wrapText(ctx, text, maxWidth, maxLines) {
806
+ const trimmed = text.trim();
807
+ if (!trimmed) {
808
+ return { lines: [], truncated: false };
809
+ }
810
+ const words = trimmed.split(/\s+/u);
811
+ const lines = [];
812
+ let current = "";
813
+ for (const word of words) {
814
+ const trial = current.length > 0 ? `${current} ${word}` : word;
815
+ if (ctx.measureText(trial).width <= maxWidth) {
816
+ current = trial;
817
+ continue;
818
+ }
819
+ if (current.length > 0) {
820
+ lines.push(current);
821
+ current = word;
822
+ } else {
823
+ lines.push(word);
824
+ current = "";
825
+ }
826
+ if (lines.length >= maxLines) {
827
+ break;
828
+ }
829
+ }
830
+ if (lines.length < maxLines && current.length > 0) {
831
+ lines.push(current);
832
+ }
833
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
834
+ if (!wasTruncated) {
835
+ return { lines, truncated: false };
836
+ }
837
+ const lastIndex = lines.length - 1;
838
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
839
+ while (truncatedLine.length > 1 && ctx.measureText(truncatedLine).width > maxWidth) {
840
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
841
+ }
842
+ lines[lastIndex] = truncatedLine;
843
+ return { lines, truncated: true };
844
+ }
845
+ function drawTextBlock(ctx, options) {
846
+ applyFont(ctx, { size: options.fontSize, weight: options.fontWeight, family: options.family });
847
+ const wrapped = wrapText(ctx, options.text, options.maxWidth, options.maxLines);
848
+ ctx.fillStyle = options.color;
849
+ for (const [index, line] of wrapped.lines.entries()) {
850
+ ctx.fillText(line, options.x, options.y + index * options.lineHeight);
851
+ }
852
+ return {
853
+ height: wrapped.lines.length * options.lineHeight,
854
+ truncated: wrapped.truncated
855
+ };
856
+ }
857
+ function drawTextLabel(ctx, text, position, options) {
858
+ applyFont(ctx, { size: options.fontSize, weight: 600, family: options.fontFamily });
859
+ const textWidth = Math.ceil(ctx.measureText(text).width);
860
+ const rect = {
861
+ x: Math.round(position.x - (textWidth + options.padding * 2) / 2),
862
+ y: Math.round(position.y - (options.fontSize + options.padding * 2) / 2),
863
+ width: textWidth + options.padding * 2,
864
+ height: options.fontSize + options.padding * 2
865
+ };
866
+ drawRoundedRect(ctx, rect, options.borderRadius, options.backgroundColor);
867
+ ctx.fillStyle = options.color;
868
+ ctx.fillText(text, rect.x + options.padding, rect.y + rect.height - options.padding);
869
+ return rect;
870
+ }
871
+
872
+ // src/renderers/card.ts
873
+ var TONE_BADGE_COLORS = {
874
+ neutral: "#334B83",
875
+ accent: "#1E7A58",
876
+ success: "#166A45",
877
+ warning: "#7A5418",
878
+ error: "#8A2C2C"
879
+ };
880
+ function renderCard(ctx, card, rect, theme) {
881
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
882
+ const bodyFont = resolveFont(theme.fonts.body, "body");
883
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
884
+ ctx.lineWidth = 1;
885
+ drawRoundedRect(ctx, rect, 14, theme.surface, theme.border);
886
+ const elements = [];
887
+ const padding = 18;
888
+ const innerLeft = rect.x + padding;
889
+ const innerWidth = rect.width - padding * 2;
890
+ let cursorY = rect.y + padding;
891
+ if (card.badge) {
892
+ applyFont(ctx, { size: 13, weight: 700, family: monoFont });
893
+ const label = card.badge.toUpperCase();
894
+ const badgeWidth = Math.ceil(ctx.measureText(label).width + 18);
895
+ const badgeRect = {
896
+ x: innerLeft,
897
+ y: cursorY,
898
+ width: badgeWidth,
899
+ height: 24
900
+ };
901
+ const badgeBg = TONE_BADGE_COLORS[card.tone ?? "neutral"];
902
+ drawRoundedRect(ctx, badgeRect, 12, badgeBg);
903
+ ctx.fillStyle = "#FFFFFF";
904
+ ctx.fillText(label, badgeRect.x + 9, badgeRect.y + 16);
905
+ elements.push({
906
+ id: `card-${card.id}-badge`,
907
+ kind: "badge",
908
+ bounds: badgeRect,
909
+ foregroundColor: "#FFFFFF",
910
+ backgroundColor: badgeBg
911
+ });
912
+ cursorY += 34;
913
+ }
914
+ const titleBlock = drawTextBlock(ctx, {
915
+ x: innerLeft,
916
+ y: cursorY + 22,
917
+ maxWidth: innerWidth,
918
+ lineHeight: 26,
919
+ color: theme.text,
920
+ text: card.title,
921
+ maxLines: 2,
922
+ fontSize: 22,
923
+ fontWeight: 700,
924
+ family: headingFont
925
+ });
926
+ cursorY += titleBlock.height + 18;
927
+ const bodyBlock = drawTextBlock(ctx, {
928
+ x: innerLeft,
929
+ y: cursorY + 20,
930
+ maxWidth: innerWidth,
931
+ lineHeight: 22,
932
+ color: theme.textMuted,
933
+ text: card.body,
934
+ maxLines: 4,
935
+ fontSize: 18,
936
+ fontWeight: 500,
937
+ family: bodyFont
938
+ });
939
+ let cardTruncated = titleBlock.truncated || bodyBlock.truncated;
940
+ if (card.metric) {
941
+ applyFont(ctx, { size: 34, weight: 700, family: headingFont });
942
+ ctx.fillStyle = theme.accent;
943
+ ctx.fillText(card.metric, innerLeft, rect.y + rect.height - 20);
944
+ elements.push({
945
+ id: `card-${card.id}-metric`,
946
+ kind: "text",
947
+ bounds: {
948
+ x: innerLeft,
949
+ y: rect.y + rect.height - 54,
950
+ width: innerWidth,
951
+ height: 40
952
+ },
953
+ foregroundColor: theme.accent,
954
+ backgroundColor: theme.surface
955
+ });
956
+ }
957
+ if (cursorY + bodyBlock.height + 24 > rect.y + rect.height) {
958
+ cardTruncated = true;
959
+ }
960
+ elements.push({
961
+ id: `card-${card.id}`,
962
+ kind: "card",
963
+ bounds: rect,
964
+ foregroundColor: theme.text,
965
+ backgroundColor: theme.surface,
966
+ truncated: cardTruncated
967
+ });
968
+ elements.push({
969
+ id: `card-${card.id}-body`,
970
+ kind: "text",
971
+ bounds: {
972
+ x: innerLeft,
973
+ y: rect.y + 10,
974
+ width: innerWidth,
975
+ height: rect.height - 20
976
+ },
977
+ foregroundColor: theme.textMuted,
978
+ backgroundColor: theme.surface,
979
+ truncated: cardTruncated
980
+ });
981
+ return elements;
982
+ }
983
+
984
+ // src/utils/color.ts
985
+ function parseChannel(hex, offset) {
986
+ return Number.parseInt(hex.slice(offset, offset + 2), 16);
987
+ }
988
+ function parseHexColor2(hexColor) {
989
+ const normalized = hexColor.startsWith("#") ? hexColor.slice(1) : hexColor;
990
+ if (normalized.length !== 6 && normalized.length !== 8) {
991
+ throw new Error(`Unsupported color format: ${hexColor}`);
992
+ }
993
+ return {
994
+ r: parseChannel(normalized, 0),
995
+ g: parseChannel(normalized, 2),
996
+ b: parseChannel(normalized, 4)
997
+ };
998
+ }
999
+ function srgbToLinear(channel) {
1000
+ const normalized = channel / 255;
1001
+ if (normalized <= 0.03928) {
1002
+ return normalized / 12.92;
1003
+ }
1004
+ return ((normalized + 0.055) / 1.055) ** 2.4;
1005
+ }
1006
+ function relativeLuminance(hexColor) {
1007
+ const rgb = parseHexColor2(hexColor);
1008
+ const r = srgbToLinear(rgb.r);
1009
+ const g = srgbToLinear(rgb.g);
1010
+ const b = srgbToLinear(rgb.b);
1011
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1012
+ }
1013
+
1014
+ // src/primitives/window-chrome.ts
1015
+ var WINDOW_CHROME_HEIGHT = 34;
1016
+ var WINDOW_CHROME_LEFT_MARGIN = 14;
1017
+ var DOT_RADIUS = 6;
1018
+ var DOT_SPACING = 20;
1019
+ var DOT_STROKE_WIDTH = 0.5;
1020
+ var MACOS_DOTS = [
1021
+ { fill: "#FF5F56", stroke: "#E0443E" },
1022
+ { fill: "#FFBD2E", stroke: "#DEA123" },
1023
+ { fill: "#27C93F", stroke: "#1AAB29" }
1024
+ ];
1025
+ function drawMacosDots(ctx, x, y) {
1026
+ for (const [index, dot] of MACOS_DOTS.entries()) {
1027
+ ctx.beginPath();
1028
+ ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
1029
+ ctx.closePath();
1030
+ ctx.fillStyle = dot.fill;
1031
+ ctx.strokeStyle = dot.stroke;
1032
+ ctx.lineWidth = DOT_STROKE_WIDTH;
1033
+ ctx.fill();
1034
+ ctx.stroke();
1035
+ }
1036
+ }
1037
+ function drawBwDots(ctx, x, y) {
1038
+ for (let index = 0; index < 3; index += 1) {
1039
+ ctx.beginPath();
1040
+ ctx.arc(x + index * DOT_SPACING, y, DOT_RADIUS, 0, Math.PI * 2);
1041
+ ctx.closePath();
1042
+ ctx.strokeStyle = "#878787";
1043
+ ctx.lineWidth = DOT_STROKE_WIDTH;
1044
+ ctx.stroke();
1045
+ }
1046
+ }
1047
+ function resolveTitleColor(backgroundColor) {
1048
+ try {
1049
+ return relativeLuminance(backgroundColor) < 0.4 ? "#FFFFFF" : "#000000";
1050
+ } catch {
1051
+ return "#FFFFFF";
1052
+ }
1053
+ }
1054
+ function drawWindowChrome(ctx, containerRect, options) {
1055
+ if (options.style === "none") {
1056
+ return { contentTop: containerRect.y, hasChrome: false };
1057
+ }
1058
+ const controlsCenterY = containerRect.y + WINDOW_CHROME_HEIGHT / 2;
1059
+ const controlsStartX = containerRect.x + WINDOW_CHROME_LEFT_MARGIN + DOT_RADIUS;
1060
+ if (options.style === "macos") {
1061
+ drawMacosDots(ctx, controlsStartX, controlsCenterY);
1062
+ } else {
1063
+ drawBwDots(ctx, controlsStartX, controlsCenterY);
1064
+ }
1065
+ if (options.title) {
1066
+ applyFont(ctx, { size: 14, weight: 500, family: options.fontFamily });
1067
+ ctx.fillStyle = resolveTitleColor(options.backgroundColor);
1068
+ ctx.textAlign = "center";
1069
+ ctx.textBaseline = "middle";
1070
+ ctx.fillText(options.title, containerRect.x + containerRect.width / 2, controlsCenterY);
1071
+ ctx.textAlign = "left";
1072
+ ctx.textBaseline = "alphabetic";
1073
+ }
1074
+ return { contentTop: containerRect.y + WINDOW_CHROME_HEIGHT, hasChrome: true };
1075
+ }
1076
+
1077
+ // src/syntax/highlighter.ts
1078
+ import { createHighlighter } from "shiki";
1079
+ var highlighterInstance = null;
1080
+ var loadedThemes = [
1081
+ "github-dark-default",
1082
+ "github-light-default",
1083
+ "dracula",
1084
+ "github-dark",
1085
+ "one-dark-pro",
1086
+ "nord"
1087
+ ];
1088
+ var loadedLanguages = [
1089
+ "typescript",
1090
+ "javascript",
1091
+ "python",
1092
+ "bash",
1093
+ "json",
1094
+ "yaml",
1095
+ "rust",
1096
+ "go",
1097
+ "html",
1098
+ "css",
1099
+ "markdown",
1100
+ "sql",
1101
+ "shell",
1102
+ "plaintext"
1103
+ ];
1104
+ var languageAliases = {
1105
+ ts: "typescript",
1106
+ js: "javascript",
1107
+ py: "python",
1108
+ sh: "bash",
1109
+ shellscript: "shell",
1110
+ yml: "yaml",
1111
+ md: "markdown",
1112
+ text: "plaintext",
1113
+ txt: "plaintext"
1114
+ };
1115
+ async function initHighlighter() {
1116
+ if (highlighterInstance) {
1117
+ return highlighterInstance;
1118
+ }
1119
+ highlighterInstance = await createHighlighter({
1120
+ themes: [...loadedThemes],
1121
+ langs: [...loadedLanguages]
1122
+ });
1123
+ return highlighterInstance;
1124
+ }
1125
+ function isLoadedTheme(theme) {
1126
+ return loadedThemes.includes(theme);
1127
+ }
1128
+ function isLoadedLanguage(language) {
1129
+ return loadedLanguages.includes(language);
1130
+ }
1131
+ function resolveLanguage(language) {
1132
+ const normalized = languageAliases[language.trim().toLowerCase()] ?? language.trim().toLowerCase();
1133
+ if (!isLoadedLanguage(normalized)) {
1134
+ throw new Error(`Unsupported language: ${language}`);
1135
+ }
1136
+ return normalized;
1137
+ }
1138
+ function resolveTheme(themeName) {
1139
+ if (!isLoadedTheme(themeName)) {
1140
+ throw new Error(`Unsupported theme: ${themeName}`);
1141
+ }
1142
+ return themeName;
1143
+ }
1144
+ function normalizeTokenColor(token) {
1145
+ return token.color ?? "#E2E8F0";
1146
+ }
1147
+ async function highlightCode(code, language, themeName) {
1148
+ const highlighter = await initHighlighter();
1149
+ const tokens = highlighter.codeToTokensBase(code, {
1150
+ lang: resolveLanguage(language),
1151
+ theme: resolveTheme(themeName)
1152
+ });
1153
+ return tokens.map((line) => ({
1154
+ tokens: line.map((token) => ({
1155
+ text: token.content,
1156
+ color: normalizeTokenColor(token)
1157
+ }))
1158
+ }));
1159
+ }
1160
+
1161
+ // src/themes/builtin.ts
1162
+ import { z } from "zod";
1163
+ var colorHexSchema = z.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
1164
+ var fontFamilySchema = z.string().min(1).max(120);
1165
+ var codeThemeSchema = z.object({
1166
+ background: colorHexSchema,
1167
+ text: colorHexSchema,
1168
+ comment: colorHexSchema,
1169
+ keyword: colorHexSchema,
1170
+ string: colorHexSchema,
1171
+ number: colorHexSchema,
1172
+ function: colorHexSchema,
1173
+ variable: colorHexSchema,
1174
+ operator: colorHexSchema,
1175
+ punctuation: colorHexSchema
1176
+ }).strict();
1177
+ var themeSchema = z.object({
1178
+ background: colorHexSchema,
1179
+ surface: colorHexSchema,
1180
+ surfaceMuted: colorHexSchema,
1181
+ surfaceElevated: colorHexSchema,
1182
+ text: colorHexSchema,
1183
+ textMuted: colorHexSchema,
1184
+ textInverse: colorHexSchema,
1185
+ primary: colorHexSchema,
1186
+ secondary: colorHexSchema,
1187
+ accent: colorHexSchema,
1188
+ success: colorHexSchema,
1189
+ warning: colorHexSchema,
1190
+ error: colorHexSchema,
1191
+ info: colorHexSchema,
1192
+ border: colorHexSchema,
1193
+ borderMuted: colorHexSchema,
1194
+ code: codeThemeSchema,
1195
+ fonts: z.object({
1196
+ heading: fontFamilySchema,
1197
+ body: fontFamilySchema,
1198
+ mono: fontFamilySchema
1199
+ }).strict()
1200
+ }).strict();
1201
+ var builtInThemeSchema = z.enum([
1202
+ "dark",
1203
+ "light",
1204
+ "dracula",
1205
+ "github-dark",
1206
+ "one-dark",
1207
+ "nord"
1208
+ ]);
1209
+ var baseDarkTheme = {
1210
+ background: "#0B1020",
1211
+ surface: "#111936",
1212
+ surfaceMuted: "#1A2547",
1213
+ surfaceElevated: "#202D55",
1214
+ text: "#E8EEFF",
1215
+ textMuted: "#AAB9E8",
1216
+ textInverse: "#0B1020",
1217
+ primary: "#7AA2FF",
1218
+ secondary: "#65E4A3",
1219
+ accent: "#65E4A3",
1220
+ success: "#2FCB7E",
1221
+ warning: "#F4B860",
1222
+ error: "#F97070",
1223
+ info: "#60A5FA",
1224
+ border: "#32426E",
1225
+ borderMuted: "#24345F",
1226
+ code: {
1227
+ background: "#0F172A",
1228
+ text: "#E2E8F0",
1229
+ comment: "#64748B",
1230
+ keyword: "#C084FC",
1231
+ string: "#86EFAC",
1232
+ number: "#FCA5A5",
1233
+ function: "#93C5FD",
1234
+ variable: "#E2E8F0",
1235
+ operator: "#F8FAFC",
1236
+ punctuation: "#CBD5E1"
1237
+ },
1238
+ fonts: {
1239
+ heading: "Space Grotesk",
1240
+ body: "Inter",
1241
+ mono: "JetBrains Mono"
1242
+ }
1243
+ };
1244
+ var builtInThemes = {
1245
+ dark: baseDarkTheme,
1246
+ light: {
1247
+ ...baseDarkTheme,
1248
+ background: "#F8FAFC",
1249
+ surface: "#FFFFFF",
1250
+ surfaceMuted: "#EEF2FF",
1251
+ surfaceElevated: "#FFFFFF",
1252
+ text: "#0F172A",
1253
+ textMuted: "#334155",
1254
+ textInverse: "#F8FAFC",
1255
+ border: "#CBD5E1",
1256
+ borderMuted: "#E2E8F0",
1257
+ code: {
1258
+ ...baseDarkTheme.code,
1259
+ background: "#F1F5F9",
1260
+ text: "#0F172A",
1261
+ variable: "#1E293B",
1262
+ punctuation: "#334155",
1263
+ operator: "#0F172A"
1264
+ }
1265
+ },
1266
+ dracula: {
1267
+ ...baseDarkTheme,
1268
+ background: "#282A36",
1269
+ surface: "#303247",
1270
+ surfaceMuted: "#3A3D55",
1271
+ surfaceElevated: "#44475A",
1272
+ text: "#F8F8F2",
1273
+ textMuted: "#BD93F9",
1274
+ primary: "#8BE9FD",
1275
+ accent: "#50FA7B",
1276
+ secondary: "#FFB86C",
1277
+ success: "#50FA7B",
1278
+ warning: "#FFB86C",
1279
+ error: "#FF5555",
1280
+ info: "#8BE9FD",
1281
+ border: "#44475A",
1282
+ borderMuted: "#3A3D55"
1283
+ },
1284
+ "github-dark": {
1285
+ ...baseDarkTheme,
1286
+ background: "#0D1117",
1287
+ surface: "#161B22",
1288
+ surfaceMuted: "#1F2632",
1289
+ surfaceElevated: "#21262D",
1290
+ text: "#E6EDF3",
1291
+ textMuted: "#8B949E",
1292
+ primary: "#58A6FF",
1293
+ accent: "#3FB950",
1294
+ secondary: "#A5D6FF",
1295
+ border: "#30363D",
1296
+ borderMuted: "#21262D"
1297
+ },
1298
+ "one-dark": {
1299
+ ...baseDarkTheme,
1300
+ background: "#282C34",
1301
+ surface: "#2F343F",
1302
+ surfaceMuted: "#3A404C",
1303
+ surfaceElevated: "#434A59",
1304
+ text: "#ABB2BF",
1305
+ textMuted: "#7F848E",
1306
+ primary: "#61AFEF",
1307
+ accent: "#98C379",
1308
+ secondary: "#E5C07B",
1309
+ warning: "#E5C07B",
1310
+ error: "#E06C75",
1311
+ border: "#4B5263",
1312
+ borderMuted: "#3A404C"
1313
+ },
1314
+ nord: {
1315
+ ...baseDarkTheme,
1316
+ background: "#2E3440",
1317
+ surface: "#3B4252",
1318
+ surfaceMuted: "#434C5E",
1319
+ surfaceElevated: "#4C566A",
1320
+ text: "#ECEFF4",
1321
+ textMuted: "#D8DEE9",
1322
+ primary: "#88C0D0",
1323
+ accent: "#A3BE8C",
1324
+ secondary: "#81A1C1",
1325
+ success: "#A3BE8C",
1326
+ warning: "#EBCB8B",
1327
+ error: "#BF616A",
1328
+ info: "#5E81AC",
1329
+ border: "#4C566A",
1330
+ borderMuted: "#434C5E"
1331
+ }
1332
+ };
1333
+ var defaultTheme = builtInThemes.dark;
1334
+
1335
+ // src/themes/syntax.ts
1336
+ var themeToShikiMap = {
1337
+ dark: "github-dark-default",
1338
+ light: "github-light-default",
1339
+ dracula: "dracula",
1340
+ "github-dark": "github-dark",
1341
+ "one-dark": "one-dark-pro",
1342
+ nord: "nord"
1343
+ };
1344
+ function isLightTheme(background) {
1345
+ const hex = background.startsWith("#") ? background.slice(1) : background;
1346
+ const normalized = hex.length === 8 ? hex.slice(0, 6) : hex;
1347
+ if (!/^[0-9a-fA-F]{6}$/u.test(normalized)) {
1348
+ return false;
1349
+ }
1350
+ const r = Number.parseInt(normalized.slice(0, 2), 16);
1351
+ const g = Number.parseInt(normalized.slice(2, 4), 16);
1352
+ const b = Number.parseInt(normalized.slice(4, 6), 16);
1353
+ const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
1354
+ return luminance > 0.6;
1355
+ }
1356
+ function matchBuiltInTheme(theme) {
1357
+ for (const [name, builtInTheme] of Object.entries(builtInThemes)) {
1358
+ if (builtInTheme === theme) {
1359
+ return name;
1360
+ }
1361
+ }
1362
+ return void 0;
1363
+ }
1364
+ function resolveShikiTheme(theme) {
1365
+ if (typeof theme === "string") {
1366
+ return themeToShikiMap[theme] ?? themeToShikiMap.dark;
1367
+ }
1368
+ const builtInName = matchBuiltInTheme(theme);
1369
+ if (builtInName) {
1370
+ return themeToShikiMap[builtInName] ?? themeToShikiMap.dark;
1371
+ }
1372
+ return isLightTheme(theme.background) ? themeToShikiMap.light : themeToShikiMap.dark;
1373
+ }
1374
+
1375
+ // src/themes/index.ts
1376
+ function resolveTheme2(theme) {
1377
+ if (typeof theme === "string") {
1378
+ return builtInThemes[theme];
1379
+ }
1380
+ return theme;
1381
+ }
1382
+
1383
+ // src/renderers/code.ts
1384
+ var fallbackKeywords = /* @__PURE__ */ new Set([
1385
+ "const",
1386
+ "let",
1387
+ "var",
1388
+ "function",
1389
+ "return",
1390
+ "if",
1391
+ "else",
1392
+ "for",
1393
+ "while",
1394
+ "do",
1395
+ "switch",
1396
+ "case",
1397
+ "default",
1398
+ "break",
1399
+ "continue",
1400
+ "class",
1401
+ "extends",
1402
+ "implements",
1403
+ "interface",
1404
+ "type",
1405
+ "enum",
1406
+ "import",
1407
+ "export",
1408
+ "from",
1409
+ "as",
1410
+ "async",
1411
+ "await",
1412
+ "try",
1413
+ "catch",
1414
+ "throw",
1415
+ "new"
1416
+ ]);
1417
+ var CONTAINER_RADIUS = 5;
1418
+ function tokenizeFallbackLine(line, theme) {
1419
+ if (line.trim().length === 0) {
1420
+ return [{ text: line, color: theme.code.text }];
1421
+ }
1422
+ const tokens = [];
1423
+ let cursor = 0;
1424
+ const push = (text, color) => {
1425
+ if (text.length > 0) {
1426
+ tokens.push({ text, color });
1427
+ }
1428
+ };
1429
+ while (cursor < line.length) {
1430
+ const rest = line.slice(cursor);
1431
+ const whitespace = rest.match(/^\s+/u);
1432
+ if (whitespace) {
1433
+ push(whitespace[0], theme.code.text);
1434
+ cursor += whitespace[0].length;
1435
+ continue;
1436
+ }
1437
+ if (rest.startsWith("//")) {
1438
+ push(rest, theme.code.comment);
1439
+ break;
1440
+ }
1441
+ const stringMatch = rest.match(/^(['"`])(?:\\.|(?!\1).)*\1/u);
1442
+ if (stringMatch) {
1443
+ push(stringMatch[0], theme.code.string);
1444
+ cursor += stringMatch[0].length;
1445
+ continue;
1446
+ }
1447
+ const numberMatch = rest.match(/^\d+(?:\.\d+)?/u);
1448
+ if (numberMatch) {
1449
+ push(numberMatch[0], theme.code.number);
1450
+ cursor += numberMatch[0].length;
1451
+ continue;
1452
+ }
1453
+ const operatorMatch = rest.match(/^(===|!==|==|!=|<=|>=|=>|&&|\|\||[+\-*/%=<>!&|^~?:])/u);
1454
+ if (operatorMatch) {
1455
+ push(operatorMatch[0], theme.code.operator);
1456
+ cursor += operatorMatch[0].length;
1457
+ continue;
1458
+ }
1459
+ const punctuationMatch = rest.match(/^[()[\]{}.,;]/u);
1460
+ if (punctuationMatch) {
1461
+ push(punctuationMatch[0], theme.code.punctuation);
1462
+ cursor += punctuationMatch[0].length;
1463
+ continue;
1464
+ }
1465
+ const identifierMatch = rest.match(/^[A-Za-z_$][A-Za-z0-9_$]*/u);
1466
+ if (identifierMatch) {
1467
+ const identifier = identifierMatch[0];
1468
+ const nextChar = rest[identifier.length];
1469
+ if (fallbackKeywords.has(identifier)) {
1470
+ push(identifier, theme.code.keyword);
1471
+ } else if (nextChar === "(") {
1472
+ push(identifier, theme.code.function);
1473
+ } else {
1474
+ push(identifier, theme.code.variable);
1475
+ }
1476
+ cursor += identifier.length;
1477
+ continue;
1478
+ }
1479
+ push(rest[0], theme.code.text);
1480
+ cursor += 1;
1481
+ }
1482
+ return tokens.length > 0 ? tokens : [{ text: line, color: theme.code.text }];
1483
+ }
1484
+ function fallbackHighlightedLines(code, theme) {
1485
+ return code.split(/\r?\n/u).map((line) => ({
1486
+ tokens: tokenizeFallbackLine(line, theme)
1487
+ }));
1488
+ }
1489
+ function insetBounds(bounds, horizontal, vertical) {
1490
+ return {
1491
+ x: bounds.x + horizontal,
1492
+ y: bounds.y + vertical,
1493
+ width: Math.max(1, bounds.width - horizontal * 2),
1494
+ height: Math.max(1, bounds.height - vertical * 2)
1495
+ };
1496
+ }
1497
+ async function renderCodeBlock(ctx, block, bounds, theme) {
1498
+ const bodyFont = resolveFont(theme.fonts.body, "body");
1499
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
1500
+ const style = resolveCodeBlockStyle(block.style);
1501
+ ctx.fillStyle = style.surroundColor;
1502
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
1503
+ const containerRect = insetBounds(bounds, style.paddingHorizontal, style.paddingVertical);
1504
+ ctx.save();
1505
+ if (style.dropShadow) {
1506
+ ctx.shadowColor = "rgba(0, 0, 0, 0.55)";
1507
+ ctx.shadowOffsetX = 0;
1508
+ ctx.shadowOffsetY = style.dropShadowOffsetY;
1509
+ ctx.shadowBlur = style.dropShadowBlurRadius;
1510
+ }
1511
+ drawRoundedRect(ctx, containerRect, CONTAINER_RADIUS, theme.code.background);
1512
+ ctx.restore();
1513
+ ctx.save();
1514
+ roundRectPath(ctx, containerRect, CONTAINER_RADIUS);
1515
+ ctx.clip();
1516
+ const chrome = drawWindowChrome(ctx, containerRect, {
1517
+ style: style.windowControls,
1518
+ title: block.title ?? block.language,
1519
+ fontFamily: bodyFont,
1520
+ backgroundColor: theme.code.background
1521
+ });
1522
+ const contentTopPadding = chrome.hasChrome ? 48 : 18;
1523
+ const contentRect = {
1524
+ x: containerRect.x + 12,
1525
+ y: containerRect.y + contentTopPadding,
1526
+ width: Math.max(1, containerRect.width - 30),
1527
+ height: Math.max(1, containerRect.height - contentTopPadding - 18)
1528
+ };
1529
+ const shikiTheme = block.theme ?? resolveShikiTheme(theme);
1530
+ let lines;
1531
+ try {
1532
+ lines = await highlightCode(block.code, block.language, shikiTheme);
1533
+ } catch {
1534
+ lines = fallbackHighlightedLines(block.code, theme);
1535
+ }
1536
+ applyFont(ctx, { size: style.fontSize, weight: 500, family: monoFont });
1537
+ const firstLine = block.startLine ?? 1;
1538
+ const highlighted = new Set(block.highlightLines ?? []);
1539
+ const lineNumberWidth = block.showLineNumbers ? Math.max(28, ctx.measureText(String(firstLine + Math.max(0, lines.length - 1))).width + 12) : 0;
1540
+ const lineHeight = Math.max(1, Math.round(style.fontSize * style.lineHeightPercent / 100));
1541
+ const firstBaselineY = contentRect.y + style.fontSize;
1542
+ const contentBottom = contentRect.y + contentRect.height;
1543
+ for (const [index, line] of lines.entries()) {
1544
+ const lineNumber = firstLine + index;
1545
+ const y = firstBaselineY + index * lineHeight;
1546
+ if (y > contentBottom) {
1547
+ break;
1548
+ }
1549
+ const lineTextWidth = line.tokens.reduce(
1550
+ (total, token) => total + ctx.measureText(token.text).width,
1551
+ 0
1552
+ );
1553
+ if (highlighted.has(lineNumber)) {
1554
+ ctx.fillStyle = "rgba(122, 162, 255, 0.18)";
1555
+ ctx.fillRect(
1556
+ contentRect.x - 4,
1557
+ y - lineHeight + 4,
1558
+ lineNumberWidth + lineTextWidth + 12,
1559
+ lineHeight
1560
+ );
1561
+ }
1562
+ let x = contentRect.x;
1563
+ if (block.showLineNumbers) {
1564
+ ctx.fillStyle = theme.code.comment;
1565
+ ctx.fillText(String(lineNumber).padStart(2, " "), x, y);
1566
+ x += lineNumberWidth;
1567
+ }
1568
+ for (const token of line.tokens) {
1569
+ ctx.fillStyle = token.color || theme.code.text;
1570
+ ctx.fillText(token.text, x, y);
1571
+ x += ctx.measureText(token.text).width;
1572
+ }
1573
+ }
1574
+ ctx.restore();
1575
+ return [
1576
+ {
1577
+ id: `code-${block.id}`,
1578
+ kind: "code-block",
1579
+ bounds,
1580
+ foregroundColor: theme.code.text,
1581
+ backgroundColor: theme.code.background
1582
+ },
1583
+ {
1584
+ id: `code-${block.id}-content`,
1585
+ kind: "text",
1586
+ bounds: contentRect,
1587
+ foregroundColor: theme.code.text,
1588
+ backgroundColor: theme.code.background
1589
+ }
1590
+ ];
1591
+ }
1592
+
1593
+ // src/primitives/lines.ts
1594
+ function applyLineStyle(ctx, style) {
1595
+ ctx.strokeStyle = style.color;
1596
+ ctx.lineWidth = style.width;
1597
+ ctx.setLineDash(style.dash ?? []);
1598
+ }
1599
+ function drawLine(ctx, from, to, style) {
1600
+ applyLineStyle(ctx, style);
1601
+ ctx.beginPath();
1602
+ ctx.moveTo(from.x, from.y);
1603
+ ctx.lineTo(to.x, to.y);
1604
+ ctx.stroke();
1605
+ }
1606
+ function drawArrowhead(ctx, tip, angle, size, fill) {
1607
+ const wing = Math.PI / 7;
1608
+ ctx.save();
1609
+ ctx.setLineDash([]);
1610
+ ctx.beginPath();
1611
+ ctx.moveTo(tip.x, tip.y);
1612
+ ctx.lineTo(tip.x - size * Math.cos(angle - wing), tip.y - size * Math.sin(angle - wing));
1613
+ ctx.lineTo(tip.x - size * Math.cos(angle + wing), tip.y - size * Math.sin(angle + wing));
1614
+ ctx.closePath();
1615
+ ctx.fillStyle = fill;
1616
+ ctx.fill();
1617
+ ctx.restore();
1618
+ }
1619
+ function drawArrow(ctx, from, to, arrow, style) {
1620
+ drawLine(ctx, from, to, style);
1621
+ if (arrow === "none") {
1622
+ return;
1623
+ }
1624
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
1625
+ if (arrow === "end" || arrow === "both") {
1626
+ drawArrowhead(ctx, to, angle, style.headSize, style.color);
1627
+ }
1628
+ if (arrow === "start" || arrow === "both") {
1629
+ drawArrowhead(ctx, from, angle + Math.PI, style.headSize, style.color);
1630
+ }
1631
+ }
1632
+ function drawBezier(ctx, points, style) {
1633
+ if (points.length < 2) {
1634
+ return;
1635
+ }
1636
+ if (points.length === 2) {
1637
+ drawLine(ctx, points[0], points[1], style);
1638
+ return;
1639
+ }
1640
+ applyLineStyle(ctx, style);
1641
+ ctx.beginPath();
1642
+ ctx.moveTo(points[0].x, points[0].y);
1643
+ if (points.length === 4) {
1644
+ ctx.bezierCurveTo(points[1].x, points[1].y, points[2].x, points[2].y, points[3].x, points[3].y);
1645
+ ctx.stroke();
1646
+ return;
1647
+ }
1648
+ for (let i = 1; i < points.length - 2; i += 1) {
1649
+ const xc = (points[i].x + points[i + 1].x) / 2;
1650
+ const yc = (points[i].y + points[i + 1].y) / 2;
1651
+ ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
1652
+ }
1653
+ const penultimate = points[points.length - 2];
1654
+ const last = points[points.length - 1];
1655
+ ctx.quadraticCurveTo(penultimate.x, penultimate.y, last.x, last.y);
1656
+ ctx.stroke();
1657
+ }
1658
+ function drawOrthogonalPath(ctx, from, to, style) {
1659
+ const midX = (from.x + to.x) / 2;
1660
+ applyLineStyle(ctx, style);
1661
+ ctx.beginPath();
1662
+ ctx.moveTo(from.x, from.y);
1663
+ ctx.lineTo(midX, from.y);
1664
+ ctx.lineTo(midX, to.y);
1665
+ ctx.lineTo(to.x, to.y);
1666
+ ctx.stroke();
1667
+ }
1668
+
1669
+ // src/renderers/connection.ts
1670
+ function center(rect) {
1671
+ return {
1672
+ x: rect.x + rect.width / 2,
1673
+ y: rect.y + rect.height / 2
1674
+ };
1675
+ }
1676
+ function edgeAnchor(rect, target) {
1677
+ const c = center(rect);
1678
+ const dx = target.x - c.x;
1679
+ const dy = target.y - c.y;
1680
+ if (Math.abs(dx) >= Math.abs(dy)) {
1681
+ return {
1682
+ x: dx >= 0 ? rect.x + rect.width : rect.x,
1683
+ y: c.y
1684
+ };
1685
+ }
1686
+ return {
1687
+ x: c.x,
1688
+ y: dy >= 0 ? rect.y + rect.height : rect.y
1689
+ };
1690
+ }
1691
+ function dashFromStyle(style) {
1692
+ switch (style) {
1693
+ case "dashed":
1694
+ return [10, 6];
1695
+ case "dotted":
1696
+ return [2, 6];
1697
+ default:
1698
+ return void 0;
1699
+ }
1700
+ }
1701
+ function pointAlongPolyline(points, t) {
1702
+ if (points.length <= 1) {
1703
+ return points[0] ?? { x: 0, y: 0 };
1704
+ }
1705
+ const lengths = [];
1706
+ let total = 0;
1707
+ for (let i = 0; i < points.length - 1; i += 1) {
1708
+ const segment = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
1709
+ lengths.push(segment);
1710
+ total += segment;
1711
+ }
1712
+ if (total === 0) {
1713
+ return points[0];
1714
+ }
1715
+ let target = total * t;
1716
+ for (let i = 0; i < lengths.length; i += 1) {
1717
+ if (target > lengths[i]) {
1718
+ target -= lengths[i];
1719
+ continue;
1720
+ }
1721
+ const ratio = lengths[i] === 0 ? 0 : target / lengths[i];
1722
+ return {
1723
+ x: points[i].x + (points[i + 1].x - points[i].x) * ratio,
1724
+ y: points[i].y + (points[i + 1].y - points[i].y) * ratio
1725
+ };
1726
+ }
1727
+ return points[points.length - 1];
1728
+ }
1729
+ function drawCubicInterpolatedPath(ctx, points, style) {
1730
+ if (points.length < 2) {
1731
+ return;
1732
+ }
1733
+ ctx.strokeStyle = style.color;
1734
+ ctx.lineWidth = style.width;
1735
+ ctx.setLineDash(style.dash ?? []);
1736
+ ctx.beginPath();
1737
+ ctx.moveTo(points[0].x, points[0].y);
1738
+ if (points.length === 2) {
1739
+ ctx.lineTo(points[1].x, points[1].y);
1740
+ ctx.stroke();
1741
+ return;
1742
+ }
1743
+ for (let i = 0; i < points.length - 1; i += 1) {
1744
+ const p0 = points[i - 1] ?? points[i];
1745
+ const p1 = points[i];
1746
+ const p2 = points[i + 1];
1747
+ const p3 = points[i + 2] ?? p2;
1748
+ const cp1 = {
1749
+ x: p1.x + (p2.x - p0.x) / 6,
1750
+ y: p1.y + (p2.y - p0.y) / 6
1751
+ };
1752
+ const cp2 = {
1753
+ x: p2.x - (p3.x - p1.x) / 6,
1754
+ y: p2.y - (p3.y - p1.y) / 6
1755
+ };
1756
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y);
1757
+ }
1758
+ ctx.stroke();
1759
+ }
1760
+ function polylineBounds(points) {
1761
+ const minX = Math.min(...points.map((point) => point.x));
1762
+ const maxX = Math.max(...points.map((point) => point.x));
1763
+ const minY = Math.min(...points.map((point) => point.y));
1764
+ const maxY = Math.max(...points.map((point) => point.y));
1765
+ return {
1766
+ x: minX,
1767
+ y: minY,
1768
+ width: Math.max(1, maxX - minX),
1769
+ height: Math.max(1, maxY - minY)
1770
+ };
1771
+ }
1772
+ function renderConnection(ctx, conn, fromBounds, toBounds, theme, edgeRoute) {
1773
+ const fromCenter = center(fromBounds);
1774
+ const toCenter = center(toBounds);
1775
+ const from = edgeAnchor(fromBounds, toCenter);
1776
+ const to = edgeAnchor(toBounds, fromCenter);
1777
+ const dash = dashFromStyle(conn.style);
1778
+ const style = {
1779
+ color: conn.color ?? theme.borderMuted,
1780
+ width: conn.width ?? 2,
1781
+ headSize: conn.arrowSize ?? 10,
1782
+ ...dash ? { dash } : {}
1783
+ };
1784
+ 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];
1785
+ const startSegment = points[1] ?? points[0];
1786
+ const endStart = points[points.length - 2] ?? points[0];
1787
+ const end = points[points.length - 1] ?? points[0];
1788
+ let startAngle = Math.atan2(startSegment.y - points[0].y, startSegment.x - points[0].x) + Math.PI;
1789
+ let endAngle = Math.atan2(end.y - endStart.y, end.x - endStart.x);
1790
+ if (!Number.isFinite(startAngle)) {
1791
+ startAngle = 0;
1792
+ }
1793
+ if (!Number.isFinite(endAngle)) {
1794
+ endAngle = 0;
1795
+ }
1796
+ const t = conn.labelPosition === "start" ? 0.2 : conn.labelPosition === "end" ? 0.8 : 0.5;
1797
+ const labelPoint = pointAlongPolyline(points, t);
1798
+ ctx.save();
1799
+ ctx.globalAlpha = conn.opacity;
1800
+ if (edgeRoute && edgeRoute.points.length >= 2) {
1801
+ drawCubicInterpolatedPath(ctx, points, style);
1802
+ } else {
1803
+ drawOrthogonalPath(ctx, points[0], points[points.length - 1], style);
1804
+ }
1805
+ if (conn.arrow === "start" || conn.arrow === "both") {
1806
+ drawArrowhead(ctx, points[0], startAngle, style.headSize, style.color);
1807
+ }
1808
+ if (conn.arrow === "end" || conn.arrow === "both") {
1809
+ drawArrowhead(ctx, end, endAngle, style.headSize, style.color);
1810
+ }
1811
+ ctx.restore();
1812
+ const elements = [
1813
+ {
1814
+ id: `connection-${conn.from}-${conn.to}`,
1815
+ kind: "connection",
1816
+ bounds: polylineBounds(points),
1817
+ foregroundColor: style.color
1818
+ }
1819
+ ];
1820
+ if (conn.label) {
1821
+ ctx.save();
1822
+ ctx.globalAlpha = conn.opacity;
1823
+ const labelRect = drawTextLabel(ctx, conn.label, labelPoint, {
1824
+ fontSize: 12,
1825
+ fontFamily: resolveFont(theme.fonts.body, "body"),
1826
+ color: theme.text,
1827
+ backgroundColor: theme.background,
1828
+ padding: 6,
1829
+ borderRadius: 8
1830
+ });
1831
+ ctx.restore();
1832
+ elements.push({
1833
+ id: `connection-${conn.from}-${conn.to}-label`,
1834
+ kind: "text",
1835
+ bounds: labelRect,
1836
+ foregroundColor: theme.text,
1837
+ backgroundColor: theme.background
1838
+ });
1839
+ }
1840
+ return elements;
1841
+ }
1842
+
1843
+ // src/utils/svg-path.ts
1844
+ var TOKEN_RE = /[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/gu;
1845
+ function isCommandToken(token) {
1846
+ return /^[A-Za-z]$/u.test(token);
1847
+ }
1848
+ function isNumberToken(token) {
1849
+ return !isCommandToken(token);
1850
+ }
1851
+ function readNumber(tokens, cursor) {
1852
+ const token = tokens[cursor.index];
1853
+ if (token === void 0 || isCommandToken(token)) {
1854
+ throw new Error(`Expected number at token index ${cursor.index}`);
1855
+ }
1856
+ cursor.index += 1;
1857
+ const value = Number(token);
1858
+ if (Number.isNaN(value)) {
1859
+ throw new Error(`Invalid number token: ${token}`);
1860
+ }
1861
+ return value;
1862
+ }
1863
+ function parseSvgPath(pathData) {
1864
+ const tokens = pathData.match(TOKEN_RE) ?? [];
1865
+ if (tokens.length === 0) {
1866
+ return [];
1867
+ }
1868
+ const operations = [];
1869
+ const cursor = { index: 0 };
1870
+ let command = "";
1871
+ let currentX = 0;
1872
+ let currentY = 0;
1873
+ let subpathStartX = 0;
1874
+ let subpathStartY = 0;
1875
+ while (cursor.index < tokens.length) {
1876
+ const token = tokens[cursor.index];
1877
+ if (token === void 0) {
1878
+ break;
1879
+ }
1880
+ if (isCommandToken(token)) {
1881
+ command = token;
1882
+ cursor.index += 1;
1883
+ } else if (!command) {
1884
+ throw new Error(`Path data must start with a command. Found: ${token}`);
1885
+ }
1886
+ switch (command) {
1887
+ case "M":
1888
+ case "m": {
1889
+ let pairIndex = 0;
1890
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
1891
+ const x = readNumber(tokens, cursor);
1892
+ const y = readNumber(tokens, cursor);
1893
+ const nextX = command === "m" ? currentX + x : x;
1894
+ const nextY = command === "m" ? currentY + y : y;
1895
+ if (pairIndex === 0) {
1896
+ operations.push({ type: "M", x: nextX, y: nextY });
1897
+ subpathStartX = nextX;
1898
+ subpathStartY = nextY;
1899
+ } else {
1900
+ operations.push({ type: "L", x: nextX, y: nextY });
1901
+ }
1902
+ currentX = nextX;
1903
+ currentY = nextY;
1904
+ pairIndex += 1;
1905
+ }
1906
+ break;
1907
+ }
1908
+ case "L":
1909
+ case "l": {
1910
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
1911
+ const x = readNumber(tokens, cursor);
1912
+ const y = readNumber(tokens, cursor);
1913
+ const nextX = command === "l" ? currentX + x : x;
1914
+ const nextY = command === "l" ? currentY + y : y;
1915
+ operations.push({ type: "L", x: nextX, y: nextY });
1916
+ currentX = nextX;
1917
+ currentY = nextY;
1918
+ }
1919
+ break;
1920
+ }
1921
+ case "H":
1922
+ case "h": {
1923
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
1924
+ const x = readNumber(tokens, cursor);
1925
+ const nextX = command === "h" ? currentX + x : x;
1926
+ operations.push({ type: "L", x: nextX, y: currentY });
1927
+ currentX = nextX;
1928
+ }
1929
+ break;
1930
+ }
1931
+ case "V":
1932
+ case "v": {
1933
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
1934
+ const y = readNumber(tokens, cursor);
1935
+ const nextY = command === "v" ? currentY + y : y;
1936
+ operations.push({ type: "L", x: currentX, y: nextY });
1937
+ currentY = nextY;
1938
+ }
1939
+ break;
1940
+ }
1941
+ case "C":
1942
+ case "c": {
1943
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
1944
+ const cp1x = readNumber(tokens, cursor);
1945
+ const cp1y = readNumber(tokens, cursor);
1946
+ const cp2x = readNumber(tokens, cursor);
1947
+ const cp2y = readNumber(tokens, cursor);
1948
+ const x = readNumber(tokens, cursor);
1949
+ const y = readNumber(tokens, cursor);
1950
+ const next = command === "c" ? {
1951
+ type: "C",
1952
+ cp1x: currentX + cp1x,
1953
+ cp1y: currentY + cp1y,
1954
+ cp2x: currentX + cp2x,
1955
+ cp2y: currentY + cp2y,
1956
+ x: currentX + x,
1957
+ y: currentY + y
1958
+ } : { type: "C", cp1x, cp1y, cp2x, cp2y, x, y };
1959
+ operations.push(next);
1960
+ currentX = next.x;
1961
+ currentY = next.y;
1962
+ }
1963
+ break;
1964
+ }
1965
+ case "Q":
1966
+ case "q": {
1967
+ while (cursor.index < tokens.length && isNumberToken(tokens[cursor.index] ?? "")) {
1968
+ const cpx = readNumber(tokens, cursor);
1969
+ const cpy = readNumber(tokens, cursor);
1970
+ const x = readNumber(tokens, cursor);
1971
+ const y = readNumber(tokens, cursor);
1972
+ const next = command === "q" ? {
1973
+ type: "Q",
1974
+ cpx: currentX + cpx,
1975
+ cpy: currentY + cpy,
1976
+ x: currentX + x,
1977
+ y: currentY + y
1978
+ } : { type: "Q", cpx, cpy, x, y };
1979
+ operations.push(next);
1980
+ currentX = next.x;
1981
+ currentY = next.y;
1982
+ }
1983
+ break;
1984
+ }
1985
+ case "Z":
1986
+ case "z": {
1987
+ operations.push({ type: "Z" });
1988
+ currentX = subpathStartX;
1989
+ currentY = subpathStartY;
1990
+ break;
1991
+ }
1992
+ default:
1993
+ throw new Error(`Unsupported SVG path command: ${command}`);
1994
+ }
1995
+ }
1996
+ return operations;
1997
+ }
1998
+
1999
+ // src/renderers/draw.ts
2000
+ function withOpacity(ctx, opacity, draw) {
2001
+ ctx.save();
2002
+ ctx.globalAlpha = opacity;
2003
+ draw();
2004
+ ctx.restore();
2005
+ }
2006
+ function expandRect(rect, amount) {
2007
+ if (amount <= 0) {
2008
+ return rect;
2009
+ }
2010
+ return {
2011
+ x: rect.x - amount,
2012
+ y: rect.y - amount,
2013
+ width: rect.width + amount * 2,
2014
+ height: rect.height + amount * 2
2015
+ };
2016
+ }
2017
+ function fromPoints(points) {
2018
+ const minX = Math.min(...points.map((point) => point.x));
2019
+ const minY = Math.min(...points.map((point) => point.y));
2020
+ const maxX = Math.max(...points.map((point) => point.x));
2021
+ const maxY = Math.max(...points.map((point) => point.y));
2022
+ return {
2023
+ x: minX,
2024
+ y: minY,
2025
+ width: Math.max(1, maxX - minX),
2026
+ height: Math.max(1, maxY - minY)
2027
+ };
2028
+ }
2029
+ function resolveDrawFont(theme, family) {
2030
+ return resolveFont(theme.fonts[family], family);
2031
+ }
2032
+ function measureSpacedTextWidth(ctx, text, letterSpacing) {
2033
+ const chars = [...text];
2034
+ if (chars.length === 0) {
2035
+ return 0;
2036
+ }
2037
+ let width = 0;
2038
+ for (const char of chars) {
2039
+ width += ctx.measureText(char).width;
2040
+ }
2041
+ if (chars.length > 1) {
2042
+ width += letterSpacing * (chars.length - 1);
2043
+ }
2044
+ return width;
2045
+ }
2046
+ function drawTextWithLetterSpacing(ctx, text, x, y, align, letterSpacing) {
2047
+ const chars = [...text];
2048
+ if (chars.length === 0) {
2049
+ return;
2050
+ }
2051
+ const totalWidth = measureSpacedTextWidth(ctx, text, letterSpacing);
2052
+ let cursor = x;
2053
+ if (align === "center") {
2054
+ cursor = x - totalWidth / 2;
2055
+ } else if (align === "right") {
2056
+ cursor = x - totalWidth;
2057
+ }
2058
+ const originalAlign = ctx.textAlign;
2059
+ ctx.textAlign = "left";
2060
+ for (const [index, char] of chars.entries()) {
2061
+ ctx.fillText(char, cursor, y);
2062
+ const spacing = index < chars.length - 1 ? letterSpacing : 0;
2063
+ cursor += ctx.measureText(char).width + spacing;
2064
+ }
2065
+ ctx.textAlign = originalAlign;
2066
+ }
2067
+ function measureTextBounds(ctx, options) {
2068
+ const measuredWidth = options.letterSpacing > 0 ? measureSpacedTextWidth(ctx, options.text, options.letterSpacing) : ctx.measureText(options.text).width;
2069
+ const width = options.maxWidth ? Math.min(measuredWidth, options.maxWidth) : measuredWidth;
2070
+ const metrics = ctx.measureText(options.text);
2071
+ const ascent = metrics.actualBoundingBoxAscent || 0;
2072
+ const descent = metrics.actualBoundingBoxDescent || 0;
2073
+ const height = Math.max(1, ascent + descent || Math.ceil((ascent || 0) * 1.35) || 1);
2074
+ const leftX = options.align === "center" ? options.x - width / 2 : options.align === "right" ? options.x - width : options.x;
2075
+ let topY = options.y - ascent;
2076
+ if (options.baseline === "top") {
2077
+ topY = options.y;
2078
+ } else if (options.baseline === "middle") {
2079
+ topY = options.y - height / 2;
2080
+ } else if (options.baseline === "bottom") {
2081
+ topY = options.y - height;
2082
+ }
2083
+ return {
2084
+ x: leftX,
2085
+ y: topY,
2086
+ width: Math.max(1, width),
2087
+ height
2088
+ };
2089
+ }
2090
+ function angleBetween(from, to) {
2091
+ return Math.atan2(to.y - from.y, to.x - from.x);
2092
+ }
2093
+ function pathBounds(operations) {
2094
+ let minX = Number.POSITIVE_INFINITY;
2095
+ let minY = Number.POSITIVE_INFINITY;
2096
+ let maxX = Number.NEGATIVE_INFINITY;
2097
+ let maxY = Number.NEGATIVE_INFINITY;
2098
+ const include = (x, y) => {
2099
+ minX = Math.min(minX, x);
2100
+ minY = Math.min(minY, y);
2101
+ maxX = Math.max(maxX, x);
2102
+ maxY = Math.max(maxY, y);
2103
+ };
2104
+ let currentX = 0;
2105
+ let currentY = 0;
2106
+ let subpathStartX = 0;
2107
+ let subpathStartY = 0;
2108
+ for (const operation of operations) {
2109
+ switch (operation.type) {
2110
+ case "M":
2111
+ include(operation.x, operation.y);
2112
+ currentX = operation.x;
2113
+ currentY = operation.y;
2114
+ subpathStartX = operation.x;
2115
+ subpathStartY = operation.y;
2116
+ break;
2117
+ case "L":
2118
+ include(operation.x, operation.y);
2119
+ include(currentX, currentY);
2120
+ currentX = operation.x;
2121
+ currentY = operation.y;
2122
+ break;
2123
+ case "C":
2124
+ include(currentX, currentY);
2125
+ include(operation.cp1x, operation.cp1y);
2126
+ include(operation.cp2x, operation.cp2y);
2127
+ include(operation.x, operation.y);
2128
+ currentX = operation.x;
2129
+ currentY = operation.y;
2130
+ break;
2131
+ case "Q":
2132
+ include(currentX, currentY);
2133
+ include(operation.cpx, operation.cpy);
2134
+ include(operation.x, operation.y);
2135
+ currentX = operation.x;
2136
+ currentY = operation.y;
2137
+ break;
2138
+ case "Z":
2139
+ include(currentX, currentY);
2140
+ include(subpathStartX, subpathStartY);
2141
+ currentX = subpathStartX;
2142
+ currentY = subpathStartY;
2143
+ break;
2144
+ }
2145
+ }
2146
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
2147
+ return { x: 0, y: 0, width: 0, height: 0 };
2148
+ }
2149
+ return {
2150
+ x: minX,
2151
+ y: minY,
2152
+ width: Math.max(1, maxX - minX),
2153
+ height: Math.max(1, maxY - minY)
2154
+ };
2155
+ }
2156
+ function applySvgOperations(ctx, operations) {
2157
+ ctx.beginPath();
2158
+ for (const operation of operations) {
2159
+ switch (operation.type) {
2160
+ case "M":
2161
+ ctx.moveTo(operation.x, operation.y);
2162
+ break;
2163
+ case "L":
2164
+ ctx.lineTo(operation.x, operation.y);
2165
+ break;
2166
+ case "C":
2167
+ ctx.bezierCurveTo(
2168
+ operation.cp1x,
2169
+ operation.cp1y,
2170
+ operation.cp2x,
2171
+ operation.cp2y,
2172
+ operation.x,
2173
+ operation.y
2174
+ );
2175
+ break;
2176
+ case "Q":
2177
+ ctx.quadraticCurveTo(operation.cpx, operation.cpy, operation.x, operation.y);
2178
+ break;
2179
+ case "Z":
2180
+ ctx.closePath();
2181
+ break;
2182
+ }
2183
+ }
2184
+ }
2185
+ function renderDrawCommands(ctx, commands, theme) {
2186
+ const rendered = [];
2187
+ for (const [index, command] of commands.entries()) {
2188
+ const id = `draw-${index}`;
2189
+ switch (command.type) {
2190
+ case "rect": {
2191
+ const rect = {
2192
+ x: command.x,
2193
+ y: command.y,
2194
+ width: command.width,
2195
+ height: command.height
2196
+ };
2197
+ withOpacity(ctx, command.opacity, () => {
2198
+ roundRectPath(ctx, rect, command.radius);
2199
+ if (command.fill) {
2200
+ ctx.fillStyle = command.fill;
2201
+ ctx.fill();
2202
+ }
2203
+ if (command.stroke && command.strokeWidth > 0) {
2204
+ ctx.lineWidth = command.strokeWidth;
2205
+ ctx.strokeStyle = command.stroke;
2206
+ ctx.stroke();
2207
+ }
2208
+ });
2209
+ const foregroundColor = command.stroke ?? command.fill;
2210
+ rendered.push({
2211
+ id,
2212
+ kind: "draw",
2213
+ bounds: expandRect(rect, command.strokeWidth / 2),
2214
+ ...foregroundColor ? { foregroundColor } : {},
2215
+ ...command.fill ? { backgroundColor: command.fill } : {}
2216
+ });
2217
+ break;
2218
+ }
2219
+ case "circle": {
2220
+ withOpacity(ctx, command.opacity, () => {
2221
+ ctx.beginPath();
2222
+ ctx.arc(command.cx, command.cy, command.radius, 0, Math.PI * 2);
2223
+ ctx.closePath();
2224
+ if (command.fill) {
2225
+ ctx.fillStyle = command.fill;
2226
+ ctx.fill();
2227
+ }
2228
+ if (command.stroke && command.strokeWidth > 0) {
2229
+ ctx.lineWidth = command.strokeWidth;
2230
+ ctx.strokeStyle = command.stroke;
2231
+ ctx.stroke();
2232
+ }
2233
+ });
2234
+ const foregroundColor = command.stroke ?? command.fill;
2235
+ rendered.push({
2236
+ id,
2237
+ kind: "draw",
2238
+ bounds: expandRect(
2239
+ {
2240
+ x: command.cx - command.radius,
2241
+ y: command.cy - command.radius,
2242
+ width: command.radius * 2,
2243
+ height: command.radius * 2
2244
+ },
2245
+ command.strokeWidth / 2
2246
+ ),
2247
+ ...foregroundColor ? { foregroundColor } : {},
2248
+ ...command.fill ? { backgroundColor: command.fill } : {}
2249
+ });
2250
+ break;
2251
+ }
2252
+ case "text": {
2253
+ const fontFamily = resolveDrawFont(theme, command.fontFamily);
2254
+ withOpacity(ctx, command.opacity, () => {
2255
+ applyFont(ctx, {
2256
+ size: command.fontSize,
2257
+ weight: command.fontWeight,
2258
+ family: fontFamily
2259
+ });
2260
+ ctx.fillStyle = command.color;
2261
+ ctx.textAlign = command.align;
2262
+ ctx.textBaseline = command.baseline;
2263
+ if (command.letterSpacing > 0) {
2264
+ drawTextWithLetterSpacing(
2265
+ ctx,
2266
+ command.text,
2267
+ command.x,
2268
+ command.y,
2269
+ command.align,
2270
+ command.letterSpacing
2271
+ );
2272
+ } else if (command.maxWidth) {
2273
+ ctx.fillText(command.text, command.x, command.y, command.maxWidth);
2274
+ } else {
2275
+ ctx.fillText(command.text, command.x, command.y);
2276
+ }
2277
+ });
2278
+ applyFont(ctx, {
2279
+ size: command.fontSize,
2280
+ weight: command.fontWeight,
2281
+ family: fontFamily
2282
+ });
2283
+ rendered.push({
2284
+ id,
2285
+ kind: "draw",
2286
+ bounds: measureTextBounds(ctx, {
2287
+ text: command.text,
2288
+ x: command.x,
2289
+ y: command.y,
2290
+ align: command.align,
2291
+ baseline: command.baseline,
2292
+ letterSpacing: command.letterSpacing,
2293
+ ...command.maxWidth ? { maxWidth: command.maxWidth } : {}
2294
+ }),
2295
+ foregroundColor: command.color,
2296
+ backgroundColor: theme.background
2297
+ });
2298
+ break;
2299
+ }
2300
+ case "line": {
2301
+ const from = { x: command.x1, y: command.y1 };
2302
+ const to = { x: command.x2, y: command.y2 };
2303
+ const lineAngle = angleBetween(from, to);
2304
+ withOpacity(ctx, command.opacity, () => {
2305
+ drawLine(ctx, from, to, {
2306
+ color: command.color,
2307
+ width: command.width,
2308
+ ...command.dash ? { dash: command.dash } : {}
2309
+ });
2310
+ if (command.arrow === "end" || command.arrow === "both") {
2311
+ drawArrowhead(ctx, to, lineAngle, command.arrowSize, command.color);
2312
+ }
2313
+ if (command.arrow === "start" || command.arrow === "both") {
2314
+ drawArrowhead(ctx, from, lineAngle + Math.PI, command.arrowSize, command.color);
2315
+ }
2316
+ });
2317
+ const arrowPadding = command.arrow === "none" ? 0 : command.arrowSize;
2318
+ rendered.push({
2319
+ id,
2320
+ kind: "draw",
2321
+ bounds: expandRect(fromPoints([from, to]), Math.max(command.width / 2, arrowPadding)),
2322
+ foregroundColor: command.color
2323
+ });
2324
+ break;
2325
+ }
2326
+ case "bezier": {
2327
+ const points = command.points;
2328
+ withOpacity(ctx, command.opacity, () => {
2329
+ drawBezier(ctx, points, {
2330
+ color: command.color,
2331
+ width: command.width,
2332
+ ...command.dash ? { dash: command.dash } : {}
2333
+ });
2334
+ const startAngle = points.length > 1 ? angleBetween(points[0], points[1]) : 0;
2335
+ const endAngle = points.length > 1 ? angleBetween(points[points.length - 2], points[points.length - 1]) : 0;
2336
+ if (command.arrow === "end" || command.arrow === "both") {
2337
+ drawArrowhead(
2338
+ ctx,
2339
+ points[points.length - 1],
2340
+ endAngle,
2341
+ command.arrowSize,
2342
+ command.color
2343
+ );
2344
+ }
2345
+ if (command.arrow === "start" || command.arrow === "both") {
2346
+ drawArrowhead(ctx, points[0], startAngle + Math.PI, command.arrowSize, command.color);
2347
+ }
2348
+ });
2349
+ const arrowPadding = command.arrow === "none" ? 0 : command.arrowSize;
2350
+ rendered.push({
2351
+ id,
2352
+ kind: "draw",
2353
+ bounds: expandRect(fromPoints(points), Math.max(command.width / 2, arrowPadding)),
2354
+ foregroundColor: command.color
2355
+ });
2356
+ break;
2357
+ }
2358
+ case "path": {
2359
+ const operations = parseSvgPath(command.d);
2360
+ const baseBounds = pathBounds(operations);
2361
+ withOpacity(ctx, command.opacity, () => {
2362
+ applySvgOperations(ctx, operations);
2363
+ if (command.fill) {
2364
+ ctx.fillStyle = command.fill;
2365
+ ctx.fill();
2366
+ }
2367
+ if (command.stroke && command.strokeWidth > 0) {
2368
+ ctx.lineWidth = command.strokeWidth;
2369
+ ctx.strokeStyle = command.stroke;
2370
+ ctx.stroke();
2371
+ }
2372
+ });
2373
+ const foregroundColor = command.stroke ?? command.fill;
2374
+ rendered.push({
2375
+ id,
2376
+ kind: "draw",
2377
+ bounds: expandRect(baseBounds, command.strokeWidth / 2),
2378
+ ...foregroundColor ? { foregroundColor } : {},
2379
+ ...command.fill ? { backgroundColor: command.fill } : {}
2380
+ });
2381
+ break;
2382
+ }
2383
+ case "badge": {
2384
+ const fontFamily = resolveDrawFont(theme, command.fontFamily);
2385
+ applyFont(ctx, {
2386
+ size: command.fontSize,
2387
+ weight: 600,
2388
+ family: fontFamily
2389
+ });
2390
+ const metrics = ctx.measureText(command.text);
2391
+ const textWidth = Math.ceil(metrics.width);
2392
+ const textHeight = Math.ceil(
2393
+ (metrics.actualBoundingBoxAscent || command.fontSize * 0.75) + (metrics.actualBoundingBoxDescent || command.fontSize * 0.25)
2394
+ );
2395
+ const rect = {
2396
+ x: command.x,
2397
+ y: command.y,
2398
+ width: textWidth + command.paddingX * 2,
2399
+ height: textHeight + command.paddingY * 2
2400
+ };
2401
+ withOpacity(ctx, command.opacity, () => {
2402
+ roundRectPath(ctx, rect, command.borderRadius);
2403
+ ctx.fillStyle = command.background;
2404
+ ctx.fill();
2405
+ applyFont(ctx, {
2406
+ size: command.fontSize,
2407
+ weight: 600,
2408
+ family: fontFamily
2409
+ });
2410
+ ctx.fillStyle = command.color;
2411
+ ctx.textAlign = "center";
2412
+ ctx.textBaseline = "middle";
2413
+ ctx.fillText(command.text, rect.x + rect.width / 2, rect.y + rect.height / 2);
2414
+ });
2415
+ rendered.push({
2416
+ id,
2417
+ kind: "draw",
2418
+ bounds: rect,
2419
+ foregroundColor: command.color,
2420
+ backgroundColor: command.background
2421
+ });
2422
+ break;
2423
+ }
2424
+ case "gradient-rect": {
2425
+ const rect = {
2426
+ x: command.x,
2427
+ y: command.y,
2428
+ width: command.width,
2429
+ height: command.height
2430
+ };
2431
+ withOpacity(ctx, command.opacity, () => {
2432
+ drawGradientRect(ctx, rect, command.gradient, command.radius);
2433
+ });
2434
+ rendered.push({
2435
+ id,
2436
+ kind: "draw",
2437
+ bounds: rect,
2438
+ backgroundColor: command.gradient.stops[0].color
2439
+ });
2440
+ break;
2441
+ }
2442
+ }
2443
+ }
2444
+ return rendered;
2445
+ }
2446
+
2447
+ // src/renderers/flow-node.ts
2448
+ function renderFlowNode(ctx, node, bounds, theme) {
2449
+ const fillColor = node.color ?? theme.surfaceElevated;
2450
+ const borderColor = node.borderColor ?? theme.border;
2451
+ const borderWidth = node.borderWidth ?? 2;
2452
+ const cornerRadius = node.cornerRadius ?? 16;
2453
+ const labelColor = node.labelColor ?? theme.text;
2454
+ const sublabelColor = node.sublabelColor ?? theme.textMuted;
2455
+ const labelFontSize = node.labelFontSize ?? 20;
2456
+ ctx.save();
2457
+ ctx.globalAlpha = node.opacity;
2458
+ ctx.lineWidth = borderWidth;
2459
+ switch (node.shape) {
2460
+ case "box":
2461
+ drawRoundedRect(ctx, bounds, 0, fillColor, borderColor);
2462
+ break;
2463
+ case "rounded-box":
2464
+ drawRoundedRect(ctx, bounds, cornerRadius, fillColor, borderColor);
2465
+ break;
2466
+ case "diamond":
2467
+ drawDiamond(ctx, bounds, fillColor, borderColor);
2468
+ break;
2469
+ case "circle": {
2470
+ const radius = Math.min(bounds.width, bounds.height) / 2;
2471
+ drawCircle(
2472
+ ctx,
2473
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
2474
+ radius,
2475
+ fillColor,
2476
+ borderColor
2477
+ );
2478
+ break;
2479
+ }
2480
+ case "pill":
2481
+ drawPill(ctx, bounds, fillColor, borderColor);
2482
+ break;
2483
+ case "cylinder":
2484
+ drawCylinder(ctx, bounds, fillColor, borderColor);
2485
+ break;
2486
+ case "parallelogram":
2487
+ drawParallelogram(ctx, bounds, fillColor, borderColor);
2488
+ break;
2489
+ }
2490
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
2491
+ const bodyFont = resolveFont(theme.fonts.body, "body");
2492
+ const centerX = bounds.x + bounds.width / 2;
2493
+ const centerY = bounds.y + bounds.height / 2;
2494
+ const labelY = node.sublabel ? centerY - Math.max(4, labelFontSize * 0.2) : centerY + labelFontSize * 0.3;
2495
+ ctx.textAlign = "center";
2496
+ applyFont(ctx, { size: labelFontSize, weight: 700, family: headingFont });
2497
+ ctx.fillStyle = labelColor;
2498
+ ctx.fillText(node.label, centerX, labelY);
2499
+ let textBoundsY = bounds.y + bounds.height / 2 - 18;
2500
+ let textBoundsHeight = 36;
2501
+ if (node.sublabel) {
2502
+ const sublabelFontSize = Math.max(12, Math.round(labelFontSize * 0.68));
2503
+ applyFont(ctx, { size: sublabelFontSize, weight: 500, family: bodyFont });
2504
+ ctx.fillStyle = sublabelColor;
2505
+ ctx.fillText(node.sublabel, centerX, labelY + Math.max(20, sublabelFontSize + 6));
2506
+ textBoundsY = bounds.y + bounds.height / 2 - 24;
2507
+ textBoundsHeight = 56;
2508
+ }
2509
+ ctx.restore();
2510
+ return [
2511
+ {
2512
+ id: `flow-node-${node.id}`,
2513
+ kind: "flow-node",
2514
+ bounds,
2515
+ foregroundColor: labelColor,
2516
+ backgroundColor: fillColor
2517
+ },
2518
+ {
2519
+ id: `flow-node-${node.id}-label`,
2520
+ kind: "text",
2521
+ bounds: {
2522
+ x: bounds.x + 8,
2523
+ y: textBoundsY,
2524
+ width: bounds.width - 16,
2525
+ height: textBoundsHeight
2526
+ },
2527
+ foregroundColor: labelColor,
2528
+ backgroundColor: fillColor
2529
+ }
2530
+ ];
2531
+ }
2532
+
2533
+ // src/renderers/image.ts
2534
+ import { loadImage } from "@napi-rs/canvas";
2535
+ function roundedRectPath2(ctx, bounds, radius) {
2536
+ const r = Math.max(0, Math.min(radius, Math.min(bounds.width, bounds.height) / 2));
2537
+ ctx.beginPath();
2538
+ ctx.moveTo(bounds.x + r, bounds.y);
2539
+ ctx.lineTo(bounds.x + bounds.width - r, bounds.y);
2540
+ ctx.quadraticCurveTo(bounds.x + bounds.width, bounds.y, bounds.x + bounds.width, bounds.y + r);
2541
+ ctx.lineTo(bounds.x + bounds.width, bounds.y + bounds.height - r);
2542
+ ctx.quadraticCurveTo(
2543
+ bounds.x + bounds.width,
2544
+ bounds.y + bounds.height,
2545
+ bounds.x + bounds.width - r,
2546
+ bounds.y + bounds.height
2547
+ );
2548
+ ctx.lineTo(bounds.x + r, bounds.y + bounds.height);
2549
+ ctx.quadraticCurveTo(bounds.x, bounds.y + bounds.height, bounds.x, bounds.y + bounds.height - r);
2550
+ ctx.lineTo(bounds.x, bounds.y + r);
2551
+ ctx.quadraticCurveTo(bounds.x, bounds.y, bounds.x + r, bounds.y);
2552
+ ctx.closePath();
2553
+ }
2554
+ function drawImagePlaceholder(ctx, image, bounds, theme) {
2555
+ ctx.fillStyle = theme.surfaceMuted;
2556
+ roundedRectPath2(ctx, bounds, image.borderRadius);
2557
+ ctx.fill();
2558
+ ctx.strokeStyle = theme.border;
2559
+ ctx.lineWidth = 2;
2560
+ roundedRectPath2(ctx, bounds, image.borderRadius);
2561
+ ctx.stroke();
2562
+ const label = image.alt ?? "load image";
2563
+ const fontFamily = resolveFont(theme.fonts.body, "body");
2564
+ applyFont(ctx, { size: 16, weight: 600, family: fontFamily });
2565
+ ctx.fillStyle = theme.textMuted;
2566
+ ctx.textAlign = "center";
2567
+ ctx.fillText(label, bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
2568
+ ctx.textAlign = "left";
2569
+ return [
2570
+ {
2571
+ id: `image-${image.id}`,
2572
+ kind: "image",
2573
+ bounds,
2574
+ foregroundColor: theme.textMuted,
2575
+ backgroundColor: theme.surfaceMuted
2576
+ }
2577
+ ];
2578
+ }
2579
+ async function renderImageElement(ctx, image, bounds, theme) {
2580
+ try {
2581
+ const loadedImage = await loadImage(image.src);
2582
+ let drawWidth = loadedImage.width;
2583
+ let drawHeight = loadedImage.height;
2584
+ if (image.fit !== "fill") {
2585
+ const widthRatio = bounds.width / loadedImage.width;
2586
+ const heightRatio = bounds.height / loadedImage.height;
2587
+ let scale = 1;
2588
+ if (image.fit === "contain") {
2589
+ scale = Math.min(widthRatio, heightRatio);
2590
+ } else if (image.fit === "cover") {
2591
+ scale = Math.max(widthRatio, heightRatio);
2592
+ }
2593
+ drawWidth = loadedImage.width * scale;
2594
+ drawHeight = loadedImage.height * scale;
2595
+ } else {
2596
+ drawWidth = bounds.width;
2597
+ drawHeight = bounds.height;
2598
+ }
2599
+ const drawX = bounds.x + (bounds.width - drawWidth) / 2;
2600
+ const drawY = bounds.y + (bounds.height - drawHeight) / 2;
2601
+ ctx.save();
2602
+ roundedRectPath2(ctx, bounds, image.borderRadius);
2603
+ ctx.clip();
2604
+ ctx.drawImage(loadedImage, drawX, drawY, drawWidth, drawHeight);
2605
+ ctx.restore();
2606
+ return [
2607
+ {
2608
+ id: `image-${image.id}`,
2609
+ kind: "image",
2610
+ bounds
2611
+ }
2612
+ ];
2613
+ } catch {
2614
+ return drawImagePlaceholder(ctx, image, bounds, theme);
2615
+ }
2616
+ }
2617
+
2618
+ // src/renderers/shape.ts
2619
+ function renderShapeElement(ctx, shape, bounds, theme) {
2620
+ const fill = shape.fill ?? theme.surfaceMuted;
2621
+ const stroke = shape.stroke ?? theme.border;
2622
+ ctx.lineWidth = shape.strokeWidth;
2623
+ switch (shape.shape) {
2624
+ case "rectangle":
2625
+ drawRoundedRect(ctx, bounds, 0, fill, stroke);
2626
+ break;
2627
+ case "rounded-rectangle":
2628
+ drawRoundedRect(ctx, bounds, 14, fill, stroke);
2629
+ break;
2630
+ case "circle": {
2631
+ const radius = Math.min(bounds.width, bounds.height) / 2;
2632
+ drawCircle(
2633
+ ctx,
2634
+ { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
2635
+ radius,
2636
+ fill,
2637
+ stroke
2638
+ );
2639
+ break;
2640
+ }
2641
+ case "ellipse":
2642
+ drawEllipse(ctx, bounds, fill, stroke);
2643
+ break;
2644
+ case "line":
2645
+ drawLine(
2646
+ ctx,
2647
+ { x: bounds.x, y: bounds.y + bounds.height / 2 },
2648
+ { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
2649
+ { color: stroke, width: shape.strokeWidth }
2650
+ );
2651
+ break;
2652
+ case "arrow":
2653
+ drawArrow(
2654
+ ctx,
2655
+ { x: bounds.x, y: bounds.y + bounds.height / 2 },
2656
+ { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
2657
+ "end",
2658
+ { color: stroke, width: shape.strokeWidth, headSize: Math.max(8, shape.strokeWidth * 3) }
2659
+ );
2660
+ break;
2661
+ }
2662
+ return [
2663
+ {
2664
+ id: `shape-${shape.id}`,
2665
+ kind: "shape",
2666
+ bounds,
2667
+ foregroundColor: stroke,
2668
+ backgroundColor: fill
2669
+ }
2670
+ ];
2671
+ }
2672
+
2673
+ // src/renderers/terminal.ts
2674
+ var CONTAINER_RADIUS2 = 5;
2675
+ function insetBounds2(bounds, horizontal, vertical) {
2676
+ return {
2677
+ x: bounds.x + horizontal,
2678
+ y: bounds.y + vertical,
2679
+ width: Math.max(1, bounds.width - horizontal * 2),
2680
+ height: Math.max(1, bounds.height - vertical * 2)
2681
+ };
2682
+ }
2683
+ function renderTerminal(ctx, terminal, bounds, theme) {
2684
+ const bodyFont = resolveFont(theme.fonts.body, "body");
2685
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
2686
+ const style = resolveCodeBlockStyle(terminal.style);
2687
+ ctx.fillStyle = style.surroundColor;
2688
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
2689
+ const containerRect = insetBounds2(bounds, style.paddingHorizontal, style.paddingVertical);
2690
+ ctx.save();
2691
+ if (style.dropShadow) {
2692
+ ctx.shadowColor = "rgba(0, 0, 0, 0.55)";
2693
+ ctx.shadowOffsetX = 0;
2694
+ ctx.shadowOffsetY = style.dropShadowOffsetY;
2695
+ ctx.shadowBlur = style.dropShadowBlurRadius;
2696
+ }
2697
+ drawRoundedRect(ctx, containerRect, CONTAINER_RADIUS2, theme.code.background);
2698
+ ctx.restore();
2699
+ ctx.save();
2700
+ roundRectPath(ctx, containerRect, CONTAINER_RADIUS2);
2701
+ ctx.clip();
2702
+ const chrome = drawWindowChrome(ctx, containerRect, {
2703
+ style: style.windowControls,
2704
+ title: terminal.title ?? "Terminal",
2705
+ fontFamily: bodyFont,
2706
+ backgroundColor: theme.code.background
2707
+ });
2708
+ const contentTopPadding = chrome.hasChrome ? 48 : 18;
2709
+ const contentRect = {
2710
+ x: containerRect.x + 12,
2711
+ y: containerRect.y + contentTopPadding,
2712
+ width: Math.max(1, containerRect.width - 30),
2713
+ height: Math.max(1, containerRect.height - contentTopPadding - 18)
2714
+ };
2715
+ const prompt = terminal.prompt ?? "$";
2716
+ const lines = terminal.content.split(/\r?\n/u).map((line) => {
2717
+ if (!terminal.showPrompt || line.trim().length === 0) {
2718
+ return line;
2719
+ }
2720
+ return `${prompt} ${line}`;
2721
+ });
2722
+ applyFont(ctx, { size: style.fontSize, weight: 500, family: monoFont });
2723
+ ctx.fillStyle = theme.code.text;
2724
+ const lineHeight = Math.max(1, Math.round(style.fontSize * style.lineHeightPercent / 100));
2725
+ const firstBaselineY = contentRect.y + style.fontSize;
2726
+ const contentBottom = contentRect.y + contentRect.height;
2727
+ for (const [index, line] of lines.entries()) {
2728
+ const y = firstBaselineY + index * lineHeight;
2729
+ if (y > contentBottom) {
2730
+ break;
2731
+ }
2732
+ ctx.fillText(line, contentRect.x, y);
2733
+ }
2734
+ ctx.restore();
2735
+ return [
2736
+ {
2737
+ id: `terminal-${terminal.id}`,
2738
+ kind: "terminal",
2739
+ bounds,
2740
+ foregroundColor: theme.code.text,
2741
+ backgroundColor: theme.code.background
2742
+ },
2743
+ {
2744
+ id: `terminal-${terminal.id}-content`,
2745
+ kind: "text",
2746
+ bounds: contentRect,
2747
+ foregroundColor: theme.code.text,
2748
+ backgroundColor: theme.code.background
2749
+ }
2750
+ ];
2751
+ }
2752
+
2753
+ // src/renderers/text.ts
2754
+ var TEXT_STYLE_MAP = {
2755
+ heading: { fontSize: 42, weight: 700, lineHeight: 48, familyRole: "heading" },
2756
+ subheading: { fontSize: 28, weight: 600, lineHeight: 34, familyRole: "body" },
2757
+ body: { fontSize: 20, weight: 500, lineHeight: 26, familyRole: "body" },
2758
+ caption: { fontSize: 14, weight: 500, lineHeight: 18, familyRole: "body" },
2759
+ code: { fontSize: 16, weight: 500, lineHeight: 22, familyRole: "mono" }
2760
+ };
2761
+ function renderTextElement(ctx, textEl, bounds, theme) {
2762
+ const style = TEXT_STYLE_MAP[textEl.style];
2763
+ const familyName = resolveFont(theme.fonts[style.familyRole], style.familyRole);
2764
+ const maxLines = Math.max(1, Math.floor(bounds.height / style.lineHeight));
2765
+ applyFont(ctx, { size: style.fontSize, weight: style.weight, family: familyName });
2766
+ const wrapped = wrapText(ctx, textEl.content, bounds.width, maxLines);
2767
+ ctx.fillStyle = textEl.color ?? theme.text;
2768
+ ctx.textAlign = textEl.align;
2769
+ const x = textEl.align === "center" ? bounds.x + bounds.width / 2 : textEl.align === "right" ? bounds.x + bounds.width : bounds.x;
2770
+ for (const [index, line] of wrapped.lines.entries()) {
2771
+ ctx.fillText(line, x, bounds.y + style.fontSize + index * style.lineHeight);
2772
+ }
2773
+ ctx.textAlign = "left";
2774
+ return [
2775
+ {
2776
+ id: `text-${textEl.id}`,
2777
+ kind: "text",
2778
+ bounds,
2779
+ foregroundColor: textEl.color ?? theme.text,
2780
+ truncated: wrapped.truncated
2781
+ }
2782
+ ];
2783
+ }
2784
+
2785
+ // src/spec.schema.ts
2786
+ import { z as z2 } from "zod";
2787
+ var colorHexSchema2 = z2.string().regex(/^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Expected #RRGGBB or #RRGGBBAA color");
2788
+ var gradientStopSchema = z2.object({
2789
+ offset: z2.number().min(0).max(1),
2790
+ color: colorHexSchema2
2791
+ }).strict();
2792
+ var linearGradientSchema = z2.object({
2793
+ type: z2.literal("linear"),
2794
+ angle: z2.number().default(180),
2795
+ stops: z2.array(gradientStopSchema).min(2)
2796
+ }).strict();
2797
+ var radialGradientSchema = z2.object({
2798
+ type: z2.literal("radial"),
2799
+ stops: z2.array(gradientStopSchema).min(2)
2800
+ }).strict();
2801
+ var gradientSchema = z2.discriminatedUnion("type", [linearGradientSchema, radialGradientSchema]);
2802
+ var drawFontFamilySchema = z2.enum(["heading", "body", "mono"]);
2803
+ var drawRectSchema = z2.object({
2804
+ type: z2.literal("rect"),
2805
+ x: z2.number(),
2806
+ y: z2.number(),
2807
+ width: z2.number().positive(),
2808
+ height: z2.number().positive(),
2809
+ fill: colorHexSchema2.optional(),
2810
+ stroke: colorHexSchema2.optional(),
2811
+ strokeWidth: z2.number().min(0).max(32).default(0),
2812
+ radius: z2.number().min(0).max(256).default(0),
2813
+ opacity: z2.number().min(0).max(1).default(1)
2814
+ }).strict();
2815
+ var drawCircleSchema = z2.object({
2816
+ type: z2.literal("circle"),
2817
+ cx: z2.number(),
2818
+ cy: z2.number(),
2819
+ radius: z2.number().positive(),
2820
+ fill: colorHexSchema2.optional(),
2821
+ stroke: colorHexSchema2.optional(),
2822
+ strokeWidth: z2.number().min(0).max(32).default(0),
2823
+ opacity: z2.number().min(0).max(1).default(1)
2824
+ }).strict();
2825
+ var drawTextSchema = z2.object({
2826
+ type: z2.literal("text"),
2827
+ x: z2.number(),
2828
+ y: z2.number(),
2829
+ text: z2.string().min(1).max(500),
2830
+ fontSize: z2.number().min(6).max(200).default(16),
2831
+ fontWeight: z2.number().int().min(100).max(900).default(400),
2832
+ fontFamily: drawFontFamilySchema.default("body"),
2833
+ color: colorHexSchema2.default("#FFFFFF"),
2834
+ align: z2.enum(["left", "center", "right"]).default("left"),
2835
+ baseline: z2.enum(["top", "middle", "alphabetic", "bottom"]).default("alphabetic"),
2836
+ letterSpacing: z2.number().min(-10).max(50).default(0),
2837
+ maxWidth: z2.number().positive().optional(),
2838
+ opacity: z2.number().min(0).max(1).default(1)
2839
+ }).strict();
2840
+ var drawLineSchema = z2.object({
2841
+ type: z2.literal("line"),
2842
+ x1: z2.number(),
2843
+ y1: z2.number(),
2844
+ x2: z2.number(),
2845
+ y2: z2.number(),
2846
+ color: colorHexSchema2.default("#FFFFFF"),
2847
+ width: z2.number().min(0.5).max(32).default(2),
2848
+ dash: z2.array(z2.number()).max(6).optional(),
2849
+ arrow: z2.enum(["none", "end", "start", "both"]).default("none"),
2850
+ arrowSize: z2.number().min(4).max(32).default(10),
2851
+ opacity: z2.number().min(0).max(1).default(1)
2852
+ }).strict();
2853
+ var drawPointSchema = z2.object({
2854
+ x: z2.number(),
2855
+ y: z2.number()
2856
+ }).strict();
2857
+ var drawBezierSchema = z2.object({
2858
+ type: z2.literal("bezier"),
2859
+ points: z2.array(drawPointSchema).min(2).max(20),
2860
+ color: colorHexSchema2.default("#FFFFFF"),
2861
+ width: z2.number().min(0.5).max(32).default(2),
2862
+ dash: z2.array(z2.number()).max(6).optional(),
2863
+ arrow: z2.enum(["none", "end", "start", "both"]).default("none"),
2864
+ arrowSize: z2.number().min(4).max(32).default(10),
2865
+ opacity: z2.number().min(0).max(1).default(1)
2866
+ }).strict();
2867
+ var drawPathSchema = z2.object({
2868
+ type: z2.literal("path"),
2869
+ d: z2.string().min(1).max(4e3),
2870
+ fill: colorHexSchema2.optional(),
2871
+ stroke: colorHexSchema2.optional(),
2872
+ strokeWidth: z2.number().min(0).max(32).default(0),
2873
+ opacity: z2.number().min(0).max(1).default(1)
2874
+ }).strict();
2875
+ var drawBadgeSchema = z2.object({
2876
+ type: z2.literal("badge"),
2877
+ x: z2.number(),
2878
+ y: z2.number(),
2879
+ text: z2.string().min(1).max(64),
2880
+ fontSize: z2.number().min(6).max(48).default(12),
2881
+ fontFamily: drawFontFamilySchema.default("mono"),
2882
+ color: colorHexSchema2.default("#FFFFFF"),
2883
+ background: colorHexSchema2.default("#334B83"),
2884
+ paddingX: z2.number().min(0).max(64).default(10),
2885
+ paddingY: z2.number().min(0).max(32).default(4),
2886
+ borderRadius: z2.number().min(0).max(64).default(12),
2887
+ opacity: z2.number().min(0).max(1).default(1)
2888
+ }).strict();
2889
+ var drawGradientRectSchema = z2.object({
2890
+ type: z2.literal("gradient-rect"),
2891
+ x: z2.number(),
2892
+ y: z2.number(),
2893
+ width: z2.number().positive(),
2894
+ height: z2.number().positive(),
2895
+ gradient: gradientSchema,
2896
+ radius: z2.number().min(0).max(256).default(0),
2897
+ opacity: z2.number().min(0).max(1).default(1)
2898
+ }).strict();
2899
+ var drawCommandSchema = z2.discriminatedUnion("type", [
2900
+ drawRectSchema,
2901
+ drawCircleSchema,
2902
+ drawTextSchema,
2903
+ drawLineSchema,
2904
+ drawBezierSchema,
2905
+ drawPathSchema,
2906
+ drawBadgeSchema,
2907
+ drawGradientRectSchema
2908
+ ]);
2909
+ var defaultCanvas = {
2910
+ width: 1200,
2911
+ height: 675,
2912
+ padding: 48
2913
+ };
2914
+ var defaultConstraints = {
2915
+ minContrastRatio: 4.5,
2916
+ minFooterSpacing: 16,
2917
+ checkOverlaps: true,
2918
+ maxTextTruncation: 0.1
2919
+ };
2920
+ var defaultAutoLayout = {
2921
+ mode: "auto",
2922
+ algorithm: "layered",
2923
+ direction: "TB",
2924
+ nodeSpacing: 80,
2925
+ rankSpacing: 120,
2926
+ edgeRouting: "polyline"
2927
+ };
2928
+ var defaultGridLayout = {
2929
+ mode: "grid",
2930
+ columns: 3,
2931
+ gap: 24,
2932
+ equalHeight: false
2933
+ };
2934
+ var defaultStackLayout = {
2935
+ mode: "stack",
2936
+ direction: "vertical",
2937
+ gap: 24,
2938
+ alignment: "stretch"
2939
+ };
2940
+ function inferLayout(elements, explicitLayout) {
2941
+ if (explicitLayout) {
2942
+ return explicitLayout;
2943
+ }
2944
+ const hasFlowNodes = elements.some((element) => element.type === "flow-node");
2945
+ const hasConnections = elements.some((element) => element.type === "connection");
2946
+ const hasOnlyCards = elements.every((element) => element.type === "card");
2947
+ const hasCodeOrTerminal = elements.some(
2948
+ (element) => element.type === "code-block" || element.type === "terminal"
2949
+ );
2950
+ if (hasFlowNodes && hasConnections) {
2951
+ return defaultAutoLayout;
2952
+ }
2953
+ if (hasOnlyCards) {
2954
+ return defaultGridLayout;
2955
+ }
2956
+ if (hasCodeOrTerminal) {
2957
+ return defaultStackLayout;
2958
+ }
2959
+ return defaultGridLayout;
2960
+ }
2961
+ var cardElementSchema = z2.object({
2962
+ type: z2.literal("card"),
2963
+ id: z2.string().min(1).max(120),
2964
+ title: z2.string().min(1).max(200),
2965
+ body: z2.string().min(1).max(4e3),
2966
+ badge: z2.string().min(1).max(64).optional(),
2967
+ metric: z2.string().min(1).max(80).optional(),
2968
+ tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
2969
+ icon: z2.string().min(1).max(64).optional()
2970
+ }).strict();
2971
+ var flowNodeElementSchema = z2.object({
2972
+ type: z2.literal("flow-node"),
2973
+ id: z2.string().min(1).max(120),
2974
+ shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
2975
+ label: z2.string().min(1).max(200),
2976
+ sublabel: z2.string().min(1).max(300).optional(),
2977
+ sublabelColor: colorHexSchema2.optional(),
2978
+ labelColor: colorHexSchema2.optional(),
2979
+ labelFontSize: z2.number().min(10).max(48).optional(),
2980
+ color: colorHexSchema2.optional(),
2981
+ borderColor: colorHexSchema2.optional(),
2982
+ borderWidth: z2.number().min(0.5).max(8).optional(),
2983
+ cornerRadius: z2.number().min(0).max(64).optional(),
2984
+ width: z2.number().int().min(40).max(800).optional(),
2985
+ height: z2.number().int().min(30).max(600).optional(),
2986
+ opacity: z2.number().min(0).max(1).default(1)
2987
+ }).strict();
2988
+ var connectionElementSchema = z2.object({
2989
+ type: z2.literal("connection"),
2990
+ from: z2.string().min(1).max(120),
2991
+ to: z2.string().min(1).max(120),
2992
+ style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
2993
+ arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
2994
+ label: z2.string().min(1).max(200).optional(),
2995
+ labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
2996
+ color: colorHexSchema2.optional(),
2997
+ width: z2.number().min(0.5).max(8).optional(),
2998
+ arrowSize: z2.number().min(4).max(32).optional(),
2999
+ opacity: z2.number().min(0).max(1).default(1)
3000
+ }).strict();
3001
+ var codeBlockStyleSchema = z2.object({
3002
+ paddingVertical: z2.number().min(0).max(128).default(56),
3003
+ paddingHorizontal: z2.number().min(0).max(128).default(56),
3004
+ windowControls: z2.enum(["macos", "bw", "none"]).default("macos"),
3005
+ dropShadow: z2.boolean().default(true),
3006
+ dropShadowOffsetY: z2.number().min(0).max(100).default(20),
3007
+ dropShadowBlurRadius: z2.number().min(0).max(200).default(68),
3008
+ surroundColor: z2.string().optional(),
3009
+ fontSize: z2.number().min(8).max(32).default(14),
3010
+ lineHeightPercent: z2.number().min(100).max(200).default(143),
3011
+ scale: z2.number().int().min(1).max(4).default(2)
3012
+ }).partial();
3013
+ var codeBlockElementSchema = z2.object({
3014
+ type: z2.literal("code-block"),
3015
+ id: z2.string().min(1).max(120),
3016
+ code: z2.string().min(1),
3017
+ language: z2.string().min(1).max(40),
3018
+ theme: z2.string().min(1).max(80).optional(),
3019
+ showLineNumbers: z2.boolean().default(false),
3020
+ highlightLines: z2.array(z2.number().int().positive()).max(500).optional(),
3021
+ startLine: z2.number().int().positive().default(1),
3022
+ title: z2.string().min(1).max(200).optional(),
3023
+ style: codeBlockStyleSchema.optional()
3024
+ }).strict();
3025
+ var terminalElementSchema = z2.object({
3026
+ type: z2.literal("terminal"),
3027
+ id: z2.string().min(1).max(120),
3028
+ content: z2.string().min(1),
3029
+ prompt: z2.string().min(1).max(24).optional(),
3030
+ title: z2.string().min(1).max(200).optional(),
3031
+ showPrompt: z2.boolean().default(true),
3032
+ style: codeBlockStyleSchema.optional()
3033
+ }).strict();
3034
+ var textElementSchema = z2.object({
3035
+ type: z2.literal("text"),
3036
+ id: z2.string().min(1).max(120),
3037
+ content: z2.string().min(1).max(4e3),
3038
+ style: z2.enum(["heading", "subheading", "body", "caption", "code"]),
3039
+ align: z2.enum(["left", "center", "right"]).default("left"),
3040
+ color: colorHexSchema2.optional()
3041
+ }).strict();
3042
+ var shapeElementSchema = z2.object({
3043
+ type: z2.literal("shape"),
3044
+ id: z2.string().min(1).max(120),
3045
+ shape: z2.enum(["rectangle", "rounded-rectangle", "circle", "ellipse", "line", "arrow"]),
3046
+ fill: colorHexSchema2.optional(),
3047
+ stroke: colorHexSchema2.optional(),
3048
+ strokeWidth: z2.number().min(0).max(64).default(1)
3049
+ }).strict();
3050
+ var imageElementSchema = z2.object({
3051
+ type: z2.literal("image"),
3052
+ id: z2.string().min(1).max(120),
3053
+ src: z2.string().min(1),
3054
+ alt: z2.string().max(240).optional(),
3055
+ fit: z2.enum(["contain", "cover", "fill", "none"]).default("contain"),
3056
+ borderRadius: z2.number().min(0).default(0)
3057
+ }).strict();
3058
+ var elementSchema = z2.discriminatedUnion("type", [
3059
+ cardElementSchema,
3060
+ flowNodeElementSchema,
3061
+ connectionElementSchema,
3062
+ codeBlockElementSchema,
3063
+ terminalElementSchema,
3064
+ textElementSchema,
3065
+ shapeElementSchema,
3066
+ imageElementSchema
3067
+ ]);
3068
+ var autoLayoutConfigSchema = z2.object({
3069
+ mode: z2.literal("auto"),
3070
+ algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
3071
+ direction: z2.enum(["TB", "BT", "LR", "RL"]).default("TB"),
3072
+ nodeSpacing: z2.number().int().min(0).max(512).default(80),
3073
+ rankSpacing: z2.number().int().min(0).max(512).default(120),
3074
+ edgeRouting: z2.enum(["orthogonal", "polyline", "spline"]).default("polyline"),
3075
+ aspectRatio: z2.number().min(0.5).max(3).optional()
3076
+ }).strict();
3077
+ var gridLayoutConfigSchema = z2.object({
3078
+ mode: z2.literal("grid"),
3079
+ columns: z2.number().int().min(1).max(12).default(3),
3080
+ gap: z2.number().int().min(0).max(256).default(24),
3081
+ cardMinHeight: z2.number().int().min(32).max(4096).optional(),
3082
+ cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
3083
+ equalHeight: z2.boolean().default(false)
3084
+ }).strict();
3085
+ var stackLayoutConfigSchema = z2.object({
3086
+ mode: z2.literal("stack"),
3087
+ direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
3088
+ gap: z2.number().int().min(0).max(256).default(24),
3089
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
3090
+ }).strict();
3091
+ var manualPositionSchema = z2.object({
3092
+ x: z2.number().int(),
3093
+ y: z2.number().int(),
3094
+ width: z2.number().int().positive().optional(),
3095
+ height: z2.number().int().positive().optional()
3096
+ }).strict();
3097
+ var manualLayoutConfigSchema = z2.object({
3098
+ mode: z2.literal("manual"),
3099
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
3100
+ }).strict();
3101
+ var layoutConfigSchema = z2.discriminatedUnion("mode", [
3102
+ autoLayoutConfigSchema,
3103
+ gridLayoutConfigSchema,
3104
+ stackLayoutConfigSchema,
3105
+ manualLayoutConfigSchema
3106
+ ]);
3107
+ var constraintsSchema = z2.object({
3108
+ minContrastRatio: z2.number().min(3).max(21).default(4.5),
3109
+ minFooterSpacing: z2.number().int().min(0).max(256).default(16),
3110
+ checkOverlaps: z2.boolean().default(true),
3111
+ maxTextTruncation: z2.number().min(0).max(1).default(0.1)
3112
+ }).strict();
3113
+ var headerSchema = z2.object({
3114
+ eyebrow: z2.string().min(1).max(120).optional(),
3115
+ title: z2.string().min(1).max(300),
3116
+ subtitle: z2.string().min(1).max(400).optional(),
3117
+ align: z2.enum(["left", "center", "right"]).default("center"),
3118
+ titleLetterSpacing: z2.number().min(-2).max(20).default(0),
3119
+ titleFontSize: z2.number().min(16).max(96).optional()
3120
+ }).strict();
3121
+ var footerSchema = z2.object({
3122
+ text: z2.string().min(1).max(300),
3123
+ tagline: z2.string().min(1).max(200).optional()
3124
+ }).strict();
3125
+ var decoratorSchema = z2.discriminatedUnion("type", [
3126
+ z2.object({
3127
+ type: z2.literal("rainbow-rule"),
3128
+ y: z2.enum(["after-header", "before-footer", "custom"]).default("after-header"),
3129
+ customY: z2.number().optional(),
3130
+ thickness: z2.number().positive().max(64).default(2),
3131
+ colors: z2.array(colorHexSchema2).min(2).optional(),
3132
+ margin: z2.number().min(0).max(512).default(16)
3133
+ }).strict(),
3134
+ z2.object({
3135
+ type: z2.literal("vignette"),
3136
+ intensity: z2.number().min(0).max(1).default(0.3),
3137
+ color: colorHexSchema2.default("#000000")
3138
+ }).strict(),
3139
+ z2.object({
3140
+ type: z2.literal("gradient-overlay"),
3141
+ gradient: gradientSchema,
3142
+ opacity: z2.number().min(0).max(1).default(0.5)
3143
+ }).strict()
3144
+ ]);
3145
+ var canvasSchema = z2.object({
3146
+ width: z2.number().int().min(320).max(4096).default(defaultCanvas.width),
3147
+ height: z2.number().int().min(180).max(4096).default(defaultCanvas.height),
3148
+ padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
3149
+ }).strict();
3150
+ var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
3151
+ var designSpecSchema = z2.object({
3152
+ version: z2.literal(2).default(2),
3153
+ canvas: canvasSchema.default(defaultCanvas),
3154
+ theme: themeInputSchema.default("dark"),
3155
+ background: z2.union([colorHexSchema2, gradientSchema]).optional(),
3156
+ header: headerSchema.optional(),
3157
+ elements: z2.array(elementSchema).default([]),
3158
+ footer: footerSchema.optional(),
3159
+ decorators: z2.array(decoratorSchema).default([]),
3160
+ draw: z2.array(drawCommandSchema).max(200).default([]),
3161
+ layout: layoutConfigSchema.optional(),
3162
+ constraints: constraintsSchema.default(defaultConstraints)
3163
+ }).strict().transform((spec) => ({
3164
+ ...spec,
3165
+ layout: inferLayout(spec.elements, spec.layout)
3166
+ }));
3167
+ function deriveSafeFrame(spec) {
3168
+ return {
3169
+ x: spec.canvas.padding,
3170
+ y: spec.canvas.padding,
3171
+ width: spec.canvas.width - spec.canvas.padding * 2,
3172
+ height: spec.canvas.height - spec.canvas.padding * 2
3173
+ };
3174
+ }
3175
+ function parseDesignSpec(input) {
3176
+ return designSpecSchema.parse(input);
3177
+ }
3178
+
3179
+ // src/utils/hash.ts
3180
+ import { createHash } from "crypto";
3181
+ function canonicalize(value) {
3182
+ if (Array.isArray(value)) {
3183
+ return value.map((item) => canonicalize(item));
3184
+ }
3185
+ if (value && typeof value === "object") {
3186
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, nested]) => [key, canonicalize(nested)]);
3187
+ return Object.fromEntries(entries);
3188
+ }
3189
+ return value;
3190
+ }
3191
+ function canonicalJson(value) {
3192
+ return JSON.stringify(canonicalize(value));
3193
+ }
3194
+ function sha256Hex(value) {
3195
+ return createHash("sha256").update(value).digest("hex");
3196
+ }
3197
+ function shortHash(hex, length = 12) {
3198
+ return hex.slice(0, length);
3199
+ }
3200
+
3201
+ // src/renderer.ts
3202
+ var DEFAULT_GENERATOR_VERSION = "0.2.0";
3203
+ function buildArtifactBaseName(specHash, generatorVersion) {
3204
+ const safeVersion = generatorVersion.replace(/[^0-9A-Za-z_.-]/gu, "_");
3205
+ return `design-v2-g${safeVersion}-s${shortHash(specHash)}`;
3206
+ }
3207
+ function computeSpecHash(spec) {
3208
+ return sha256Hex(canonicalJson(spec));
3209
+ }
3210
+ function resolveAlignedX(rect, align) {
3211
+ if (align === "center") {
3212
+ return rect.x + rect.width / 2;
3213
+ }
3214
+ if (align === "right") {
3215
+ return rect.x + rect.width;
3216
+ }
3217
+ return rect.x;
3218
+ }
3219
+ function measureTextWithLetterSpacing(ctx, text, letterSpacing) {
3220
+ if (letterSpacing <= 0) {
3221
+ return ctx.measureText(text).width;
3222
+ }
3223
+ const glyphs = Array.from(text);
3224
+ if (glyphs.length === 0) {
3225
+ return 0;
3226
+ }
3227
+ const base = glyphs.reduce((sum, glyph) => sum + ctx.measureText(glyph).width, 0);
3228
+ return base + letterSpacing * (glyphs.length - 1);
3229
+ }
3230
+ function wrapTextWithLetterSpacing(ctx, text, maxWidth, maxLines, letterSpacing) {
3231
+ if (letterSpacing <= 0) {
3232
+ return wrapText(ctx, text, maxWidth, maxLines);
3233
+ }
3234
+ const trimmed = text.trim();
3235
+ if (!trimmed) {
3236
+ return { lines: [], truncated: false };
3237
+ }
3238
+ const words = trimmed.split(/\s+/u);
3239
+ const lines = [];
3240
+ let current = "";
3241
+ for (const word of words) {
3242
+ const trial = current.length > 0 ? `${current} ${word}` : word;
3243
+ if (measureTextWithLetterSpacing(ctx, trial, letterSpacing) <= maxWidth) {
3244
+ current = trial;
3245
+ continue;
3246
+ }
3247
+ if (current.length > 0) {
3248
+ lines.push(current);
3249
+ current = word;
3250
+ } else {
3251
+ lines.push(word);
3252
+ current = "";
3253
+ }
3254
+ if (lines.length >= maxLines) {
3255
+ break;
3256
+ }
3257
+ }
3258
+ if (lines.length < maxLines && current.length > 0) {
3259
+ lines.push(current);
3260
+ }
3261
+ const wasTruncated = lines.length >= maxLines && words.join(" ") !== lines.join(" ");
3262
+ if (!wasTruncated) {
3263
+ return { lines, truncated: false };
3264
+ }
3265
+ const lastIndex = lines.length - 1;
3266
+ let truncatedLine = `${lines[lastIndex]}\u2026`;
3267
+ while (truncatedLine.length > 1 && measureTextWithLetterSpacing(ctx, truncatedLine, letterSpacing) > maxWidth) {
3268
+ truncatedLine = `${truncatedLine.slice(0, -2)}\u2026`;
3269
+ }
3270
+ lines[lastIndex] = truncatedLine;
3271
+ return { lines, truncated: true };
3272
+ }
3273
+ function drawAlignedTextLine(ctx, text, x, y, align, letterSpacing) {
3274
+ if (letterSpacing <= 0) {
3275
+ ctx.textAlign = align;
3276
+ ctx.fillText(text, x, y);
3277
+ return;
3278
+ }
3279
+ const glyphs = Array.from(text);
3280
+ const lineWidth = measureTextWithLetterSpacing(ctx, text, letterSpacing);
3281
+ let cursorX = x;
3282
+ if (align === "center") {
3283
+ cursorX = x - lineWidth / 2;
3284
+ } else if (align === "right") {
3285
+ cursorX = x - lineWidth;
3286
+ }
3287
+ ctx.textAlign = "left";
3288
+ for (const glyph of glyphs) {
3289
+ ctx.fillText(glyph, cursorX, y);
3290
+ cursorX += ctx.measureText(glyph).width + letterSpacing;
3291
+ }
3292
+ }
3293
+ function drawAlignedTextBlock(ctx, options) {
3294
+ applyFont(ctx, {
3295
+ size: options.fontSize,
3296
+ weight: options.fontWeight,
3297
+ family: options.fontFamily
3298
+ });
3299
+ const letterSpacing = options.letterSpacing ?? 0;
3300
+ const wrapped = wrapTextWithLetterSpacing(
3301
+ ctx,
3302
+ options.text,
3303
+ options.maxWidth,
3304
+ options.maxLines,
3305
+ letterSpacing
3306
+ );
3307
+ ctx.fillStyle = options.color;
3308
+ for (const [index, line] of wrapped.lines.entries()) {
3309
+ drawAlignedTextLine(
3310
+ ctx,
3311
+ line,
3312
+ options.x,
3313
+ options.y + index * options.lineHeight,
3314
+ options.align,
3315
+ letterSpacing
3316
+ );
3317
+ }
3318
+ return {
3319
+ height: wrapped.lines.length * options.lineHeight,
3320
+ truncated: wrapped.truncated
3321
+ };
3322
+ }
3323
+ async function renderDesign(input, options = {}) {
3324
+ loadFonts();
3325
+ const spec = parseDesignSpec(input);
3326
+ const safeFrame = deriveSafeFrame(spec);
3327
+ const theme = resolveTheme2(spec.theme);
3328
+ const specHash = computeSpecHash(spec);
3329
+ const generatorVersion = options.generatorVersion ?? DEFAULT_GENERATOR_VERSION;
3330
+ const renderedAt = options.renderedAt ?? (/* @__PURE__ */ new Date()).toISOString();
3331
+ const renderScale = resolveRenderScale(spec);
3332
+ const canvas = createCanvas(spec.canvas.width * renderScale, spec.canvas.height * renderScale);
3333
+ const ctx = canvas.getContext("2d");
3334
+ if (renderScale !== 1) {
3335
+ ctx.scale(renderScale, renderScale);
3336
+ }
3337
+ const headingFont = resolveFont(theme.fonts.heading, "heading");
3338
+ const monoFont = resolveFont(theme.fonts.mono, "mono");
3339
+ const bodyFont = resolveFont(theme.fonts.body, "body");
3340
+ const background = spec.background ?? theme.background;
3341
+ const canvasRect = { x: 0, y: 0, width: spec.canvas.width, height: spec.canvas.height };
3342
+ if (typeof background === "string") {
3343
+ ctx.fillStyle = background;
3344
+ ctx.fillRect(0, 0, spec.canvas.width, spec.canvas.height);
3345
+ } else {
3346
+ drawGradientRect(ctx, canvasRect, background);
3347
+ }
3348
+ const metadataBackground = typeof background === "string" ? background : theme.background;
3349
+ const elements = [];
3350
+ const hasHeader = Boolean(spec.header);
3351
+ const hasFooter = Boolean(spec.footer);
3352
+ const headerRect = hasHeader ? {
3353
+ x: safeFrame.x,
3354
+ y: safeFrame.y,
3355
+ width: safeFrame.width,
3356
+ height: Math.round(safeFrame.height * 0.24)
3357
+ } : void 0;
3358
+ const footerRect = hasFooter ? {
3359
+ x: safeFrame.x,
3360
+ y: safeFrame.y + safeFrame.height - Math.round(safeFrame.height * 0.1),
3361
+ width: safeFrame.width,
3362
+ height: Math.round(safeFrame.height * 0.1)
3363
+ } : void 0;
3364
+ const sectionGap = spec.layout.mode === "grid" || spec.layout.mode === "stack" ? spec.layout.gap : 24;
3365
+ const contentTop = headerRect ? headerRect.y + headerRect.height + sectionGap : safeFrame.y;
3366
+ const contentBottom = footerRect ? footerRect.y - sectionGap : safeFrame.y + safeFrame.height;
3367
+ if (headerRect && spec.header) {
3368
+ const headerAlign = spec.header.align;
3369
+ const headerX = resolveAlignedX(headerRect, headerAlign);
3370
+ if (spec.header.eyebrow) {
3371
+ applyFont(ctx, { size: 16, weight: 700, family: monoFont });
3372
+ ctx.fillStyle = theme.primary;
3373
+ ctx.textAlign = headerAlign;
3374
+ ctx.fillText(spec.header.eyebrow.toUpperCase(), headerX, headerRect.y + 18);
3375
+ }
3376
+ const titleFontSize = spec.header.titleFontSize ?? 42;
3377
+ const titleLineHeight = Math.round(titleFontSize * 1.14);
3378
+ const titleY = spec.header.eyebrow ? headerRect.y + 58 : headerRect.y + 32;
3379
+ const titleBlock = drawAlignedTextBlock(ctx, {
3380
+ x: headerX,
3381
+ y: titleY,
3382
+ maxWidth: headerRect.width,
3383
+ lineHeight: titleLineHeight,
3384
+ color: theme.text,
3385
+ text: spec.header.title,
3386
+ maxLines: 2,
3387
+ fontSize: titleFontSize,
3388
+ fontWeight: 700,
3389
+ fontFamily: headingFont,
3390
+ align: headerAlign,
3391
+ letterSpacing: spec.header.titleLetterSpacing
3392
+ });
3393
+ let subtitleTruncated = false;
3394
+ if (spec.header.subtitle) {
3395
+ const subtitleBlock = drawAlignedTextBlock(ctx, {
3396
+ x: headerX,
3397
+ y: titleY + titleBlock.height + 12,
3398
+ maxWidth: headerRect.width,
3399
+ lineHeight: 28,
3400
+ color: theme.textMuted,
3401
+ text: spec.header.subtitle,
3402
+ maxLines: 2,
3403
+ fontSize: 22,
3404
+ fontWeight: 500,
3405
+ fontFamily: bodyFont,
3406
+ align: headerAlign
3407
+ });
3408
+ subtitleTruncated = subtitleBlock.truncated;
3409
+ }
3410
+ ctx.textAlign = "left";
3411
+ elements.push({
3412
+ id: "header",
3413
+ kind: "header",
3414
+ bounds: headerRect,
3415
+ foregroundColor: theme.text,
3416
+ backgroundColor: metadataBackground
3417
+ });
3418
+ elements.push({
3419
+ id: "header-title",
3420
+ kind: "text",
3421
+ bounds: {
3422
+ x: headerRect.x,
3423
+ y: headerRect.y + 20,
3424
+ width: headerRect.width,
3425
+ height: headerRect.height - 20
3426
+ },
3427
+ foregroundColor: theme.text,
3428
+ backgroundColor: metadataBackground,
3429
+ truncated: titleBlock.truncated || subtitleTruncated
3430
+ });
3431
+ }
3432
+ const deferredVignettes = [];
3433
+ for (const [index, decorator] of spec.decorators.entries()) {
3434
+ if (decorator.type === "vignette") {
3435
+ deferredVignettes.push({ index, intensity: decorator.intensity, color: decorator.color });
3436
+ continue;
3437
+ }
3438
+ if (decorator.type === "gradient-overlay") {
3439
+ ctx.save();
3440
+ ctx.globalAlpha = decorator.opacity;
3441
+ drawGradientRect(ctx, canvasRect, decorator.gradient);
3442
+ ctx.restore();
3443
+ elements.push({
3444
+ id: `decorator-gradient-overlay-${index}`,
3445
+ kind: "gradient-overlay",
3446
+ bounds: { ...canvasRect },
3447
+ allowOverlap: true
3448
+ });
3449
+ continue;
3450
+ }
3451
+ const defaultAfterHeaderY = headerRect ? headerRect.y + headerRect.height + Math.max(4, sectionGap / 2) : safeFrame.y + 24;
3452
+ const defaultBeforeFooterY = footerRect ? footerRect.y - Math.max(4, sectionGap / 2) : safeFrame.y + safeFrame.height - 24;
3453
+ const y = decorator.y === "before-footer" ? defaultBeforeFooterY : decorator.y === "custom" ? decorator.customY ?? defaultAfterHeaderY : defaultAfterHeaderY;
3454
+ const x = safeFrame.x + decorator.margin;
3455
+ const width = Math.max(0, safeFrame.width - decorator.margin * 2);
3456
+ drawRainbowRule(ctx, x, y, width, decorator.thickness, decorator.colors);
3457
+ elements.push({
3458
+ id: `decorator-rainbow-rule-${index}`,
3459
+ kind: "rainbow-rule",
3460
+ bounds: {
3461
+ x,
3462
+ y: y - decorator.thickness / 2,
3463
+ width,
3464
+ height: decorator.thickness
3465
+ },
3466
+ allowOverlap: true
3467
+ });
3468
+ }
3469
+ const contentFrame = {
3470
+ x: safeFrame.x,
3471
+ y: contentTop,
3472
+ width: safeFrame.width,
3473
+ height: Math.max(0, contentBottom - contentTop)
3474
+ };
3475
+ const layoutResult = await computeLayout(spec.elements, spec.layout, contentFrame);
3476
+ const elementRects = layoutResult.positions;
3477
+ const edgeRoutes = layoutResult.edgeRoutes;
3478
+ for (const element of spec.elements) {
3479
+ if (element.type === "connection") {
3480
+ continue;
3481
+ }
3482
+ const rect = elementRects.get(element.id);
3483
+ if (!rect) {
3484
+ throw new Error(`Missing layout bounds for element: ${element.id}`);
3485
+ }
3486
+ switch (element.type) {
3487
+ case "card":
3488
+ elements.push(...renderCard(ctx, element, rect, theme));
3489
+ break;
3490
+ case "flow-node":
3491
+ elements.push(...renderFlowNode(ctx, element, rect, theme));
3492
+ break;
3493
+ case "terminal":
3494
+ elements.push(...renderTerminal(ctx, element, rect, theme));
3495
+ break;
3496
+ case "code-block":
3497
+ elements.push(...await renderCodeBlock(ctx, element, rect, theme));
3498
+ break;
3499
+ case "text":
3500
+ elements.push(...renderTextElement(ctx, element, rect, theme));
3501
+ break;
3502
+ case "shape":
3503
+ elements.push(...renderShapeElement(ctx, element, rect, theme));
3504
+ break;
3505
+ case "image":
3506
+ elements.push(...await renderImageElement(ctx, element, rect, theme));
3507
+ break;
3508
+ }
3509
+ }
3510
+ for (const element of spec.elements) {
3511
+ if (element.type !== "connection") {
3512
+ continue;
3513
+ }
3514
+ const fromRect = elementRects.get(element.from);
3515
+ const toRect = elementRects.get(element.to);
3516
+ if (!fromRect || !toRect) {
3517
+ throw new Error(
3518
+ `Connection endpoints must reference positioned elements: from=${element.from} to=${element.to}`
3519
+ );
3520
+ }
3521
+ const edgeRoute = edgeRoutes?.get(`${element.from}-${element.to}`);
3522
+ elements.push(...renderConnection(ctx, element, fromRect, toRect, theme, edgeRoute));
3523
+ }
3524
+ if (footerRect && spec.footer) {
3525
+ const footerText = spec.footer.tagline ? `${spec.footer.text} \u2022 ${spec.footer.tagline}` : spec.footer.text;
3526
+ applyFont(ctx, { size: 16, weight: 600, family: monoFont });
3527
+ ctx.fillStyle = theme.textMuted;
3528
+ ctx.fillText(footerText, footerRect.x, footerRect.y + footerRect.height - 10);
3529
+ elements.push({
3530
+ id: "footer",
3531
+ kind: "footer",
3532
+ bounds: footerRect,
3533
+ foregroundColor: theme.textMuted,
3534
+ backgroundColor: metadataBackground
3535
+ });
3536
+ }
3537
+ elements.push(...renderDrawCommands(ctx, spec.draw, theme));
3538
+ for (const vignette of deferredVignettes) {
3539
+ drawVignette(ctx, spec.canvas.width, spec.canvas.height, vignette.intensity, vignette.color);
3540
+ elements.push({
3541
+ id: `decorator-vignette-${vignette.index}`,
3542
+ kind: "vignette",
3543
+ bounds: { ...canvasRect },
3544
+ allowOverlap: true
3545
+ });
3546
+ }
3547
+ const pngBuffer = Buffer.from(await canvas.encode("png"));
3548
+ const artifactHash = sha256Hex(pngBuffer);
3549
+ const artifactBaseName = buildArtifactBaseName(specHash, generatorVersion);
3550
+ const metadata = {
3551
+ schemaVersion: 2,
3552
+ generatorVersion,
3553
+ renderedAt,
3554
+ specHash,
3555
+ artifactHash,
3556
+ artifactBaseName,
3557
+ canvas: {
3558
+ width: spec.canvas.width,
3559
+ height: spec.canvas.height,
3560
+ scale: renderScale
3561
+ },
3562
+ layout: {
3563
+ safeFrame,
3564
+ elements
3565
+ }
3566
+ };
3567
+ return {
3568
+ png: pngBuffer,
3569
+ metadata
3570
+ };
3571
+ }
3572
+ function resolveOutputPaths(out, artifactBaseName) {
3573
+ const resolved = resolve2(out);
3574
+ const hasPngExtension = extname(resolved).toLowerCase() === ".png";
3575
+ if (hasPngExtension) {
3576
+ const metadataPath2 = resolved.replace(/\.png$/iu, ".meta.json");
3577
+ return { imagePath: resolved, metadataPath: metadataPath2 };
3578
+ }
3579
+ const imagePath = join(resolved, `${artifactBaseName}.png`);
3580
+ const metadataPath = join(resolved, `${artifactBaseName}.meta.json`);
3581
+ return { imagePath, metadataPath };
3582
+ }
3583
+ async function writeRenderArtifacts(result, out) {
3584
+ const { imagePath, metadataPath } = resolveOutputPaths(out, result.metadata.artifactBaseName);
3585
+ await mkdir(dirname2(imagePath), { recursive: true });
3586
+ await mkdir(dirname2(metadataPath), { recursive: true });
3587
+ await writeFile(imagePath, result.png);
3588
+ await writeFile(metadataPath, JSON.stringify(result.metadata, null, 2));
3589
+ return {
3590
+ imagePath,
3591
+ metadataPath,
3592
+ metadata: result.metadata
3593
+ };
3594
+ }
3595
+ function inferSidecarPath(imagePath) {
3596
+ const resolved = resolve2(imagePath);
3597
+ if (extname(resolved).toLowerCase() !== ".png") {
3598
+ return join(dirname2(resolved), `${basename(resolved)}.meta.json`);
3599
+ }
3600
+ return resolved.replace(/\.png$/iu, ".meta.json");
3601
+ }
3602
+ export {
3603
+ DEFAULT_GENERATOR_VERSION,
3604
+ computeSpecHash,
3605
+ inferSidecarPath,
3606
+ renderDesign,
3607
+ writeRenderArtifacts
3608
+ };